Laravel Interview Questions and Answers
Last updated:
Check out 45 of the most common Laravel interview questions, then take an AI-powered practice interview
Q1How does Laravel's service container resolve a class you never bound explicitly?
BasicService Container
Answer
Laravel's container is a reflection-based autowiring container. When you type-hint a concrete class in the constructor of anything the framework resolves (controllers, jobs, listeners, console commands, middleware, form requests), the container reads the constructor signature with PHP reflection, recursively resolves each type-hinted parameter, and hands back a fully built object. For a plain concrete class with no scalar constructor arguments you never write a binding at all.
You need an explicit binding in three situations. First, when you type-hint an interface: reflection cannot guess the implementation, so you get the classic 'Target [App\Contracts\PaymentGateway] is not instantiable' error. Second, when the constructor takes scalars such as an API key or a timeout, which reflection cannot invent.
Third, when you want a shared instance, which means singleton() rather than bind(). bind() runs the resolver on every resolution, singleton() caches the instance for the life of the PHP process, and scoped() caches per request, a distinction that only starts to matter once you run Octane. Contextual binding lets two consumers of the same interface receive different implementations, which is exactly how you wire a sandbox gateway into a refund controller while every other class gets the live one. Interviewers typically follow up with two things: where bindings belong (the register() method of a service provider) and what happens if you resolve services inside register() rather than boot(). Also expect app()->make(), the resolve() helper, and method injection on controller actions to come up.
use App\Contracts\PaymentGateway;
use App\Services\RazorpayGateway;
use App\Services\SandboxGateway;
// AppServiceProvider::register()
$this->app->bind(PaymentGateway::class, RazorpayGateway::class);
$this->app->singleton(LedgerClient::class, function ($app) {
return new LedgerClient(config('services.ledger.key'));
});
$this->app->when(RefundController::class)
->needs(PaymentGateway::class)
->give(SandboxGateway::class);
// Autowired: no binding needed, the container reflects the constructor
class InvoiceController extends Controller
{
public function __construct(
private LedgerClient $ledger,
private PaymentGateway $gateway,
) {}
}
Key Points
- Concrete classes autowire through reflection with zero configuration
- Interfaces, scalar arguments and shared instances need explicit bindings
- bind() resolves every time, singleton() once per process, scoped() once per request
- Contextual binding gives different implementations to different consumers
Q2What is the difference between register() and boot() in a service provider, and why does the order matter?
BasicService Providers
Answer
Laravel boots in two passes. It calls register() on every provider first, then calls boot() on every provider. register() exists only to put things into the container: bind(), singleton(), instance(), mergeConfigFrom(). boot() runs after every provider has registered, so it is the only safe place to use other services: defining routes, registering Blade directives, adding validation rules, wiring model observers, publishing assets, calling Gate::define(). The rule most candidates get wrong is that you must not resolve services out of the container inside register().
If you call app('router') or resolve a repository there, you may pull an object whose own provider has not registered its bindings yet, and you end up with a half-configured singleton cached for the whole request. The bug is intermittent and depends on provider order in bootstrap/providers.php, which makes it painful to debug. Type-hinted dependencies in boot() are method-injected, so you can accept a Router or a Filesystem directly in the signature.
Two other details come up in interviews. Deferred providers implement the DeferrableProvider interface and expose provides(); Laravel skips them at boot and only loads them when one of their bindings is actually resolved, which keeps the framework cold-start cheap. And in Laravel 11 and later, providers are listed in bootstrap/providers.php rather than the old config/app.php providers array, while packages still register themselves through composer.json extra.laravel.providers auto-discovery.
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Support\DeferrableProvider;
class BillingServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
// Container wiring only. Never resolve anything here.
$this->mergeConfigFrom(__DIR__ . '/../config/billing.php', 'billing');
$this->app->singleton(InvoiceNumberGenerator::class);
}
public function boot(): void
{
// Everything is registered by now, so other services are safe to use.
Invoice::observe(InvoiceObserver::class);
Blade::directive('inr', fn ($expr) => "<?php echo '₹' . number_format($expr, 2); ?>");
}
public function provides(): array
{
return [InvoiceNumberGenerator::class];
}
}
Q3How do Laravel facades actually work, and when should you inject the class instead?
BasicFacades
Answer
A facade is not a static class. Every facade extends Illuminate\Support\Facades\Facade, which implements __callStatic(). When you write Cache::get('key'), PHP finds no static get() method, falls into __callStatic(), which calls getFacadeAccessor() to get a container key such as 'cache', resolves that key from the container, and forwards the call to the real instance.
So Cache::get() is a container lookup plus an instance method call wearing a static disguise. That indirection is why facades are testable: Cache::shouldReceive('get') swaps the underlying container binding for a Mockery double, and Cache::fake()-style helpers such as Queue::fake() and Http::fake() do the same thing. Real-time facades take it further: prefix any class with Facades\ in the import (use Facades\App\Services\PdfRenderer) and Laravel generates a facade for it on the fly.
When to inject instead: whenever the dependency is part of the class's contract and you want it visible in the constructor. A service class with five facade calls hides five dependencies from anyone reading the signature, and that gets flagged in code review. The practical convention most Indian teams settle on is facades in controllers, Blade views, routes and quick console commands, constructor injection inside domain and service classes. Also remember helper functions (cache(), config(), auth(), request()) are thin wrappers over the same container bindings, and that facades resolve per call, so a facade never caches a stale instance the way a constructor-injected singleton can.
// This...
use Illuminate\Support\Facades\Cache;
$plans = Cache::remember('plans', 600, fn () => Plan::all());
// ...is roughly this under the hood
$plans = app('cache')->remember('plans', 600, fn () => Plan::all());
// Explicit injection makes the dependency part of the contract
use Illuminate\Contracts\Cache\Repository as CacheRepository;
class PlanCatalogue
{
public function __construct(private CacheRepository $cache) {}
public function all(): Collection
{
return $this->cache->remember('plans', 600, fn () => Plan::all());
}
}
// Testing a facade swaps the container binding
Cache::shouldReceive('remember')->once()->andReturn(collect());
Key Points
- __callStatic() resolves a container key and forwards the call
- Facades are mockable because they proxy a swappable container binding
- Real-time facades work on any class via the Facades\ import prefix
- Inject in domain services, use facades in controllers, views and commands
Q4What is route model binding, and how do implicit, scoped and explicit bindings differ?
BasicRouting
Answer
Route model binding turns a URL segment into a hydrated Eloquent model before your controller runs. Implicit binding is the common case: type-hint a model in the action signature with a parameter name matching the route segment, and Laravel runs a findOrFail() on the primary key, returning a 404 automatically when the row does not exist. Override getRouteKeyName() on the model to bind on a slug or UUID instead, or use the inline syntax {post:slug} for one route only.
Scoped binding matters for nested resources: with /users/{user}/orders/{order}, plain implicit binding fetches the order by id with no check that it belongs to that user, which is a textbook IDOR vulnerability where changing the id in the URL exposes another customer's order. Chaining scopeBindings() on the route (or using the {order:id} child binding syntax, which Laravel scopes automatically when the parent is also bound) forces the child query through the parent relationship. Explicit binding is defined with Route::bind() in a service provider and lets you write arbitrary resolution logic, for example resolving only non-archived records or applying a tenant filter.
Two production notes. Soft-deleted models are excluded by default, and you opt back in with withTrashed() on the route. And binding happens before your controller and before most policy checks run in the action body, so pair binding with the can middleware or an authorize() call so a valid id belonging to another account still gets a 403.
// routes/web.php
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
// Scoped so the order must belong to the user (prevents IDOR)
Route::get('/users/{user}/orders/{order}', [OrderController::class, 'show'])
->scopeBindings();
// Soft-deleted models are 404 by default
Route::get('/invoices/{invoice}', ShowInvoice::class)->withTrashed();
// Explicit binding with custom resolution logic
Route::bind('tenant', function (string $value) {
return Tenant::where('subdomain', $value)->where('active', true)->firstOrFail();
});
// Model-level default key
class Post extends Model
{
public function getRouteKeyName(): string
{
return 'slug';
}
}
Q5How is middleware registered in Laravel 11 and later now that app/Http/Kernel.php is gone?
BasicMiddleware
Answer
The Laravel 11 skeleton removed app/Http/Kernel.php and moved everything it configured into bootstrap/app.php, where the Application::configure() fluent builder now owns middleware, exception handling, routing and scheduling. Inside withMiddleware(), the Middleware object exposes append() and prepend() for the global stack, web() and api() to modify those groups, alias() to register short names for route middleware, and helpers such as replace(), remove() and priority(). Route-level usage has not changed: you still chain ->middleware('auth', 'verified') on routes or call $this->middleware() from a controller constructor.
Middleware itself is unchanged too, a class with a handle($request, Closure $next) method that either passes the request down the pipeline or short-circuits with a response. Three details worth knowing. The stateful API middleware group for Sanctum SPA authentication is enabled with $middleware->statefulApi() rather than by hand-editing an array.
Terminable middleware (a terminate() method) runs after the response is sent to the browser, which is where session writes and some logging happen, and it does not run for queued jobs. And middleware parameters still use colon syntax, 'throttle:60,1' or a custom 'ensure.plan:pro', which is how you keep one class serving multiple gates. If you are working on an older Laravel 9 or 10 codebase, and plenty of Indian service projects still are, Kernel.php remains valid; Laravel 11 did not force the upgrade, it only changed what a fresh install looks like.
// bootstrap/app.php (Laravel 11+)
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
$middleware->append(TrackApiUsage::class);
$middleware->alias([
'plan' => EnsureSubscriptionPlan::class,
]);
$middleware->web(remove: [ValidateCsrfToken::class]);
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->dontReport(PaymentDeclinedException::class);
})->create();
// Usage is unchanged
Route::post('/exports', ExportController::class)->middleware('plan:pro');
Key Points
- bootstrap/app.php replaces Kernel.php with a fluent configure() builder
- append/prepend for global, web()/api() for groups, alias() for route names
- statefulApi() enables the Sanctum SPA cookie flow
- terminate() middleware runs after the response, never inside queued jobs
Q6What is the difference between $fillable and $guarded, and what exactly is a mass assignment vulnerability?
BasicEloquent
Answer
Both control which attributes Model::create() and $model->fill()/update() will accept from an array. $fillable is an allowlist, $guarded is a blocklist, and a model should use one or the other, never both. The vulnerability appears when you pass unfiltered request data straight into create() or update(). If a users table has an is_admin or wallet_balance column and the model is unguarded, an attacker adds is_admin=1 to the form POST and privilege-escalates. $guarded = ['id'] feels convenient on a small table and then becomes a hole the day someone adds a sensitive column and forgets to guard it, which is why $fillable is the safer default in any codebase that will outlive a sprint.
A few behaviours worth naming precisely. Direct property assignment ($user->is_admin = true; $user->save()) bypasses the check entirely, mass assignment protection only guards array-style filling. forceFill() and forceCreate() also bypass it, and Model::unguarded(fn () => ...) disables it for a closure, which is fine inside seeders. In recent Laravel versions, Model::preventSilentlyDiscardingAttributes() (usually enabled together with preventLazyLoading and preventAccessingMissingAttributes via Model::shouldBeStrict() in a non-production environment) throws instead of silently dropping a non-fillable key, which turns a class of quiet bugs into loud failures during development. The real fix in production code is not to trust the model layer at all: validate with a FormRequest and pass $request->validated() into create(), so only fields you explicitly declared can ever reach the database.
class User extends Model
{
protected $fillable = ['name', 'email', 'password', 'phone'];
// is_admin, wallet_balance, email_verified_at are NOT fillable
}
// Vulnerable: attacker posts is_admin=1
User::create($request->all());
// Safe: only validated keys reach the model
User::create($request->validated());
// Bypasses mass assignment protection on purpose
$user->forceFill(['is_admin' => true])->save();
// AppServiceProvider::boot()
Model::shouldBeStrict(! app()->isProduction());
Q7How do migrations work, and what is the difference between migrate:rollback, migrate:refresh and migrate:fresh?
BasicMigrations
Answer
Migrations are versioned PHP classes that describe schema changes with the Schema builder. Laravel tracks which files have run in a migrations table, along with a batch number, and php artisan migrate runs only the pending ones in filename timestamp order. The three teardown commands differ in an important way. migrate:rollback reverses the last batch by calling down() on each migration in it; --step=3 rolls back three batches. migrate:refresh rolls everything back through down() and then re-runs it all, so it respects your down() methods and only touches tables Laravel knows about. migrate:fresh drops every table in the database and re-runs all migrations from scratch, ignoring down() entirely, which is faster and immune to broken down() methods but will happily wipe tables created outside migrations.
On production, migrate:fresh and migrate:refresh are destructive; Laravel prompts for confirmation and --force skips the prompt, which is exactly why deployment scripts should only ever run php artisan migrate --force. Interviewers usually probe two things beyond the definitions. First, zero-downtime schema changes: adding a nullable column is safe, but renaming or dropping a column while old code is still running breaks requests, so you split it into expand, backfill, contract deploys. Second, index and foreign key handling: use foreignId()->constrained() with an explicit cascadeOnDelete or nullOnDelete, and remember that on large MySQL tables an ALTER can lock writes long enough to cause an outage, which is when tools like pt-online-schema-change or MySQL 8 instant DDL come into the conversation.
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('reference')->unique();
$table->unsignedBigInteger('amount_paise');
$table->string('status')->default('pending')->index();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
// Deploy script: never anything else on production
// php artisan migrate --force
Key Points
- rollback reverses the last batch via down()
- refresh rolls back everything then re-migrates, still using down()
- fresh drops all tables and ignores down() completely
- Production deploys run only migrate --force; expand/backfill/contract for risky changes
Q8Explain Eloquent's relationship types, and when you would reach for hasManyThrough or a polymorphic relation.
BasicEloquent Relationships
Answer
The core set is hasOne, hasMany, belongsTo, belongsToMany, hasOneThrough, hasManyThrough, morphOne, morphMany, morphTo and morphToMany. hasMany and belongsTo are the two halves of a one-to-many, with the foreign key living on the child table. belongsToMany models many-to-many through a pivot table, and withPivot() plus withTimestamps() expose extra pivot columns; when the pivot itself carries meaning (a role with an expiry date, an order line with a quantity) you should promote it to a real model with a custom pivot class extending Pivot, or drop the pivot idea entirely and model it as two hasMany relations to an explicit entity. hasManyThrough skips an intermediate table: a Country hasManyThrough Posts via Users, so you can call $country->posts without loading users at all. Polymorphic relations let one child table attach to several parent types via a morph type and morph id pair, which is the right shape for comments, attachments, audit log entries and notifications. The trade-off is real: morph columns cannot carry a database foreign key constraint, so referential integrity becomes your application's job, and queries across morph types cannot be joined cleanly.
Use Relation::enforceMorphMap() to store short aliases such as 'post' instead of fully qualified class names, otherwise renaming or moving a model class silently orphans every existing row. Interviewers often ask you to name the query count difference between $post->comments and a joined query, and whether whereHas() or a join is the right tool at scale.
class Post extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)
->withPivot('position')
->withTimestamps();
}
}
class Country extends Model
{
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
}
// AppServiceProvider::boot() - never store raw class names in morph columns
Relation::enforceMorphMap([
'post' => Post::class,
'invoice' => Invoice::class,
]);
Q9What is the N+1 query problem in Eloquent, and how do you make it impossible to ship?
BasicEloquent Performance
Answer
N+1 happens when you load a collection with one query, then touch a lazy relationship inside a loop, firing one extra query per row. Fifty posts rendering $post->author->name become 51 queries. Eager loading with with('author') collapses that into two queries: one for posts, one where in (...) for the authors.
The variants matter. with() eager loads up front, load() eager loads onto an already-fetched collection, loadMissing() only loads what is absent, and withCount() adds a subquery count column so you never hydrate models just to call count(). Nested and constrained eager loads work too: with(['orders.items', 'orders' => fn ($q) => $q->latest()->limit(5)]). Select only the columns you need, and always include the foreign key in a constrained select or the relation silently comes back empty, which is a classic debugging trap.
The reason this question keeps appearing in interviews is that N+1 is invisible in development with ten seeded rows and catastrophic in production with fifty thousand. The professional answer is that you do not rely on discipline, you make it throw. Model::preventLazyLoading() in non-production environments raises a LazyLoadingViolationException the moment any lazy relation is accessed, so the failure lands in your local run or your CI test suite instead of in a slow production endpoint. Pair it with Laravel Telescope or Pulse in staging and a DB::whenQueryingForLongerThan() callback in production, and you catch both the query count and the aggregate query time.
// N+1: 1 + 50 queries
$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
echo $post->author->name;
}
// 2 queries
$posts = Post::with('author:id,name')->latest()->take(50)->get();
// Counts without hydrating relations
$posts = Post::withCount(['comments', 'likes'])->get();
// Constrained nested eager load
$users = User::with([
'orders' => fn ($q) => $q->where('status', 'paid')->latest()->limit(5),
'orders.items',
])->get();
// AppServiceProvider::boot() - turn N+1 into a hard failure
Model::preventLazyLoading(! app()->isProduction());
Q10How does a FormRequest validate input, and what do bail, sometimes and nullable actually do?
BasicValidation
Answer
A FormRequest is a class generated by php artisan make:request that Laravel resolves through the container when you type-hint it in a controller action. Before the action body runs, Laravel calls authorize() (returning false yields a 403) and then runs rules() against the input. On failure it throws a ValidationException, which redirects back with errors for web requests and returns a 422 with a structured errors object for JSON requests, all without a single line in your controller.
Inside the action you call $request->validated() to get only the keys you declared, or safe()->only([...]) to slice further. The three rule keywords candidates confuse constantly: bail stops running further rules on that field after the first failure, which matters when a later rule is expensive, for example an exists lookup after a format check. nullable says an explicitly null value is acceptable and skips the remaining rules for that field; without it, a nullable database column plus a null payload throws a validation error you did not intend. sometimes means the field is only validated when it is present in the payload at all, which is how PATCH endpoints work correctly. Beyond keywords, know prepareForValidation() for normalising input before rules run (trimming a phone number, uppercasing a PAN), withValidator() or after() for cross-field checks, Rule::unique('users')->ignore($this->user) for edit forms, and array validation with dot and wildcard syntax like 'items.*.quantity' => 'integer|min:1'.
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
protected function prepareForValidation(): void
{
$this->merge(['phone' => preg_replace('/\\D/', '', (string) $this->phone)]);
}
public function rules(): array
{
return [
'phone' => ['bail', 'required', 'digits:10'],
'gstin' => ['nullable', 'string', 'size:15'],
'coupon' => ['sometimes', 'string', Rule::exists('coupons', 'code')],
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'],
];
}
}
// Controller
public function store(StoreOrderRequest $request)
{
return Order::create($request->validated());
}
Key Points
- authorize() runs before rules(); false returns 403
- bail short-circuits remaining rules for that field
- nullable permits explicit null, sometimes skips absent fields entirely
- prepareForValidation() normalises input before any rule runs
Q11In Blade, what is the difference between {{ }} and {!! !!}, and how does Laravel's CSRF protection work?
BasicBlade and Security
Answer
{{ $value }} compiles to an echo through e(), which is htmlspecialchars() with ENT_QUOTES and UTF-8, so any HTML in the value renders as text rather than markup. {!! $value !!} echoes raw, unescaped output. Every stored XSS vulnerability in a Laravel app traces back to {!! !!} on user-controlled data, or to a Blade component prop injected into an attribute or a script block without escaping. If you must render user HTML, for example rich text from a WYSIWYG editor, run it through a sanitiser such as HTMLPurifier and store the cleaned version, do not sanitise at render time.
For passing data into JavaScript, use the @json directive or the Js::from() helper, which escapes for a JavaScript context rather than an HTML one; embedding {{ $json }} inside a script tag is a different escaping problem and gets exploited. CSRF protection is separate. Laravel puts a random token in the session and requires it back on every POST, PUT, PATCH and DELETE request that goes through the web middleware group. @csrf renders the hidden _token input, and for AJAX you either send the X-CSRF-TOKEN header from a meta tag or rely on the encrypted XSRF-TOKEN cookie that Axios reads automatically.
Stateless API routes do not need it because they use bearer tokens rather than cookies, which is precisely why Sanctum's SPA mode, which does use cookies, keeps CSRF in play through the stateful API middleware. Excluding a webhook route from CSRF is legitimate; excluding it and then not verifying the provider's signature is not.
{{-- Escaped: safe by default --}}
<p>{{ $user->bio }}</p>
{{-- Raw: only for HTML you sanitised before storing --}}
<div>{!! $post->sanitised_html !!}</div>
{{-- Correct way to hand data to JavaScript --}}
<script>
window.config = @json($config);
</script>
<form method="POST" action="/orders">
@csrf
@method('PUT')
<input name="reference" value="{{ old('reference') }}">
</form>
{{-- AJAX header source --}}
<meta name="csrf-token" content="{{ csrf_token() }}">
Q12Why does env() return null in production after you run php artisan config:cache?
BasicConfiguration
Answer
config:cache serialises every file under config/ into a single bootstrap/cache/config.php file. When that file exists, Laravel loads it and never reads your .env at all, so the .env file is not parsed and env() has nothing to read from. Any env() call outside config/ then returns null, or the default you passed, which is worse because it fails quietly.
The classic symptom is a feature that works on staging and silently misbehaves in production: a service class calling env('RAZORPAY_KEY') gets null, the SDK throws an authentication error, and nobody connects it to the deploy step that added config:cache. The rule is absolute: env() belongs only in config files. Everywhere else you call config('services.razorpay.key'), and if the value is missing you add it to a config file first.
The same caching model applies to route:cache, view:cache and event:cache, and in Laravel 11 and later php artisan optimize runs the whole set, with optimize:clear reversing it. Two more details interviewers like. Closure-based routes cannot be serialised, so route:cache fails with a LogicException until you convert them to controller actions, which is why production-grade codebases avoid closure routes entirely.
And config caching is why you must run config:clear locally after editing .env, otherwise you spend twenty minutes debugging a value that changed on disk but not in the cache. On a deploy, the cache commands must run after the new code is in place and the queue workers must be restarted afterwards so they pick up the new config.
// config/services.php - the ONLY place env() should appear
return [
'razorpay' => [
'key' => env('RAZORPAY_KEY'),
'secret' => env('RAZORPAY_SECRET'),
'webhook_secret' => env('RAZORPAY_WEBHOOK_SECRET'),
],
];
// app/Services/RazorpayClient.php
class RazorpayClient
{
public function __construct()
{
// Correct
$this->key = config('services.razorpay.key');
// Returns null once config is cached
// $this->key = env('RAZORPAY_KEY');
}
}
// Deploy
// php artisan optimize && php artisan queue:restart
Key Points
- config:cache stops .env from being parsed at all
- env() is valid only inside config/, config() everywhere else
- optimize bundles config, route, view and event caches; optimize:clear reverses it
- route:cache throws on closure routes
Q13Which Artisan commands do you actually use daily, and what does php artisan about tell you?
BasicArtisan Tooling
Answer
php artisan about is the fastest way to understand an unfamiliar Laravel codebase: it prints the framework and PHP version, the environment, the debug flag, the active cache/database/queue/session/mail drivers, and whether config, routes, events and views are cached. Running it as the first command on a new project answers half the questions you would otherwise ask a teammate. Alongside it, db:show summarises the connection, tables and row counts, db:table users prints a table's columns and indexes, and model:show Order lists a model's attributes, casts, relations and observers, which is genuinely useful on a legacy codebase with a thousand-line model.
For scaffolding, make:model Order -mfsc --policy generates the model plus migration, factory, seeder, controller and policy in one call. route:list --path=api --except-vendor gives a readable route table without package noise. tinker drops you into a PsySH REPL with the full application booted, so you can run Order::whereDate('created_at', today())->sum('amount_paise') against real data instead of writing a throwaway script. migrate --pretend prints the SQL a migration would execute without running it, which is how you sanity-check a risky ALTER before a production window. schedule:list shows the next run time for every scheduled task, queue:monitor warns when a queue backs up past a threshold, and optimize plus optimize:clear manage the whole cache set. Knowing these commands is a strong signal in an interview because it separates people who have operated a Laravel app from people who have only built one.
php artisan about # framework, drivers, cache state
php artisan db:show # connection + tables + row counts
php artisan db:table orders # columns, types, indexes
php artisan model:show Order # attributes, casts, relations, observers
php artisan make:model Order -mfsc --policy
php artisan route:list --path=api --except-vendor
php artisan migrate --pretend # print SQL, execute nothing
php artisan schedule:list
php artisan queue:monitor payments:100
php artisan optimize # config + route + view + event caches
# tinker: full app booted as a REPL
php artisan tinker
>>> Order::whereDate('created_at', today())->sum('amount_paise') / 100
Q14How do accessors, mutators and attribute casts work in current Laravel, and when do you write a custom cast?
BasicEloquent
Answer
Since Laravel 9 the accessor and mutator syntax is a single method returning an Illuminate\Database\Eloquent\Casts\Attribute instance with get and set closures, replacing the old getFooAttribute/setFooAttribute pair. The method name is the camelCase form of the column, so fullName() backs $model->full_name. The get closure receives the raw value plus the full attributes array, which lets a computed accessor combine several columns.
The set closure returns an array of the underlying columns to write, so one virtual attribute can update multiple real columns. Accessors on derived values can be memoised for the life of the instance with ->shouldCache(). Casts are the declarative layer for type conversion, and in Laravel 11 and later they live in a casts() method rather than the old $casts property, which means a cast definition can now be built dynamically.
Built-ins cover integer, boolean, decimal:2, array, collection, immutable_datetime, encrypted, encrypted:array, hashed (which bcrypts on write), and native PHP enums, so casting a status column straight to a backed enum gives you type safety across the whole app. Write a custom cast implementing CastsAttributes when a value object is involved: money stored as paise but exposed as a Money object, a JSON blob deserialised into a typed DTO, or a phone number normalised on write. Custom casts keep conversion logic in one place instead of scattering intval() and json_decode() across services, and unlike accessors they participate in queries through the value passed to set.
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
protected function casts(): array
{
return [
'status' => UserStatus::class, // native PHP enum
'password' => 'hashed',
'preferences' => 'array',
'aadhaar_last4' => 'encrypted',
'verified_at' => 'immutable_datetime',
'wallet_paise' => 'integer',
];
}
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attrs) => trim($attrs['first_name'] . ' ' . $attrs['last_name']),
set: fn (string $value) => [
'first_name' => Str::before($value, ' '),
'last_name' => Str::after($value, ' '),
],
)->shouldCache();
}
}
Key Points
- One Attribute::make(get:, set:) method replaces the old getter/setter pair
- Laravel 11+ puts casts in a casts() method, enabling dynamic definitions
- hashed, encrypted and enum casts remove a lot of hand-written conversion
- Custom CastsAttributes classes are the right home for value objects
Q15When would you use a Gate versus a Policy, and how does authorization resolve in Laravel 11+?
BasicAuthorization
Answer
Gates are standalone closures registered with Gate::define() and are the right tool for abilities that are not tied to a single model: 'view-admin-dashboard', 'export-reports', 'impersonate-users'. Policies are classes whose methods map to actions on one model, generated with make:policy --model=Order, and they are the right tool for anything answering 'can this user do X to this record'. Laravel resolves policies by convention: App\Models\Order maps to App\Policies\OrderPolicy, and in Laravel 11 and later you no longer register them in an AuthServiceProvider $policies array, though you can still bind one explicitly with Gate::policy() or the UsePolicy attribute on the model when the naming does not match.
Every entry point checks the same resolver: $user->can('update', $order) in code, $this->authorize('update', $order) in a controller (which throws a 403 through AuthorizationException), the can middleware on a route, and @can in Blade. Three behaviours to get right. Policy methods for actions with no model instance, such as viewAny and create, receive only the user, so you pass the class name: $this->authorize('create', Order::class).
A before() method on the policy short-circuits every check, which is how super-admin bypasses are implemented, and it must return null rather than false when it has no opinion, otherwise it denies everything. And to allow guests, type-hint the user parameter as nullable (?User $user), otherwise Laravel denies unauthenticated requests before your method runs. Returning Response::deny('Your plan does not include exports.') instead of false gives the user a real message instead of a bare 403.
// A Gate: ability with no model
Gate::define('view-admin-dashboard', fn (User $user) => $user->is_staff);
// A Policy: abilities on a model
class OrderPolicy
{
public function before(User $user): ?bool
{
return $user->is_super_admin ? true : null; // null, never false
}
public function view(?User $user, Order $order): bool
{
return $order->is_public || $user?->id === $order->user_id;
}
public function refund(User $user, Order $order): Response
{
return $user->plan === 'pro'
? Response::allow()
: Response::deny('Refunds are available on the Pro plan only.');
}
}
// Controller + route + Blade
$this->authorize('create', Order::class);
Route::delete('/orders/{order}', ...)->middleware('can:delete,order');
// @can('refund', $order) ... @endcan
Q16How do Laravel queues work: what is the difference between a connection, a queue, and a worker?
BasicQueues
Answer
A connection is a backend defined in config/queue.php: sync, database, redis, sqs or beanstalkd. A queue is a named channel inside that connection, so one Redis connection can carry 'default', 'emails' and 'payments'. A worker is the long-running PHP process started by php artisan queue:work that pops jobs off one or more queues and executes them.
The distinction matters because prioritisation happens at the queue level: queue:work redis --queue=payments,default drains payments completely before touching default, so a burst of bulk emails cannot delay a payment confirmation. A job class implements ShouldQueue and is pushed with dispatch(); without ShouldQueue the handler runs synchronously in the request. The sync driver runs jobs inline and is useful locally, but it hides every asynchronous bug you will meet in production, so run the database or redis driver in development too.
Failed jobs land in the failed_jobs table after exhausting their retries, inspected with queue:failed, retried with queue:retry all or queue:retry <uuid>, and cleared with queue:flush. In production, workers run under Supervisor on a VM, or as a separate container or Kubernetes deployment, never as a background process started by hand, because nothing restarts them after a crash. Two configuration values are easy to get wrong: the worker's --timeout must be lower than the connection's retry_after in config/queue.php, otherwise the queue makes the job visible again while the first worker is still running it and the job executes twice. Redis is the usual choice for real workloads, with Horizon on top for metrics and autoscaling.
class SendInvoiceEmail implements ShouldQueue
{
use Queueable;
public function __construct(public int $orderId) {}
public function handle(MailService $mail): void
{
$mail->sendInvoice(Order::findOrFail($this->orderId));
}
}
// Dispatch variants
SendInvoiceEmail::dispatch($order->id)->onQueue('emails');
SendInvoiceEmail::dispatch($order->id)->delay(now()->addMinutes(5));
SendInvoiceEmail::dispatch($order->id)->afterCommit();
# Worker: payments drains before default
php artisan queue:work redis --queue=payments,default --tries=3 --timeout=60
# retry_after in config/queue.php must exceed --timeout
php artisan queue:failed
php artisan queue:retry all
Key Points
- Connection = backend, queue = named channel, worker = the process consuming it
- Queue order in --queue sets strict priority
- retry_after must be greater than the worker --timeout or jobs run twice
- Supervisor, systemd or a container orchestrator must own worker lifecycles
Q17How do Blade components work, and when do you use a class-based component versus an anonymous one?
BasicBlade
Answer
Blade compiles templates into plain PHP files under storage/framework/views and reuses them until the source changes, which is why view:cache exists for deploys and why a stale compiled view occasionally needs view:clear. Components are the reusable unit. An anonymous component is just a Blade file in resources/views/components, so components/alert.blade.php is used as <x-alert />, with a nested directory becoming a dot: components/forms/input.blade.php is <x-forms.input />.
It declares its inputs with @props(['type' => 'info']) at the top and needs no PHP class. A class-based component, generated with make:component Alert, pairs a view with a class whose public properties and methods are automatically available in the template, and whose render() method can build markup conditionally. Choose the class-based version when the component needs constructor logic, dependency injection, computed values or a shouldRender() check; choose the anonymous version, which is most of the time, when it is markup plus a few props.
The attribute bag is what makes components feel native: $attributes holds everything you did not declare as a prop, and $attributes->merge(['class' => 'btn']) combines caller classes with defaults instead of overwriting them, with ->class([...]) supporting conditional classes. Slots complete the picture: $slot is the default content, and named slots use <x-slot:title> in the caller. Beyond components, @once and @push('scripts') let a component inject its JavaScript exactly one time no matter how often it appears on a page, which is the correct way to ship component-scoped assets without duplicating script tags.
{{-- resources/views/components/stat-card.blade.php (anonymous) --}}
@props(['label', 'value', 'trend' => null])
<div {{ $attributes->merge(['class' => 'rounded-lg border p-4']) }}>
<p class="text-sm text-gray-500">{{ $label }}</p>
<p class="text-2xl font-semibold">{{ $value }}</p>
@if ($trend)
<span @class(['text-green-600' => $trend > 0, 'text-red-600' => $trend < 0])>
{{ $trend }}%
</span>
@endif
{{ $slot }}
</div>
{{-- Usage --}}
<x-stat-card label="Orders today" :value="$count" :trend="$delta" class="shadow-sm">
<x-slot:footer>Updated {{ $updatedAt->diffForHumans() }}</x-slot:footer>
</x-stat-card>
Q18How do model factories and seeders work together, and how do you build realistic related data?
BasicTesting Data
Answer
A factory is a class in database/factories whose definition() returns the default attribute set for a model, using the fake() helper for values. The model opts in with the HasFactory trait, and Laravel resolves Order to OrderFactory by convention. States are named overrides declared as methods returning $this->state(...), so Order::factory()->paid()->create() produces a paid order without repeating attribute arrays.
Relationships are expressed fluently: for() attaches a belongsTo parent, has() creates children, and count() multiplies. Sequence() cycles values across generated rows, which is how you create ten orders spread across three statuses in one call, and recycle() reuses a single parent instance across a whole tree so you do not accidentally create fifty users while creating fifty orders. Seeders are classes in database/seeders invoked from DatabaseSeeder, run with db:seed, db:seed --class=PlanSeeder, or migrate --seed.
The useful split is that seeders own reference data your app cannot function without (plans, roles, states, GST slabs) and are safe to run repeatedly, usually via updateOrCreate, while factories own throwaway volume data for local development and tests. Two India-specific touches: set faker_locale to en_IN in config/app.php so generated names, addresses and phone numbers look plausible to your QA team, and seed realistic row counts, because a seeder that creates twenty rows lets N+1 queries and missing indexes pass review that would collapse on a hundred thousand. Factories are also the backbone of feature tests, where make() builds an unsaved model and create() persists it.
class OrderFactory extends Factory
{
public function definition(): array
{
return [
'reference' => 'ORD-' . fake()->unique()->numerify('########'),
'amount_paise' => fake()->numberBetween(9900, 4999900),
'status' => 'pending',
];
}
public function paid(): static
{
return $this->state(fn () => ['status' => 'paid', 'paid_at' => now()]);
}
}
// One user, 50 orders, 3 items each, statuses cycled
$user = User::factory()
->has(
Order::factory()
->count(50)
->state(new Sequence(['status' => 'paid'], ['status' => 'pending']))
->has(OrderItem::factory()->count(3), 'items')
)
->create();
// Reuse one product catalogue instead of creating 150 products
OrderItem::factory()->count(150)->recycle(Product::factory()->count(10)->create())->create();
Q19chunk(), chunkById(), cursor() and lazy(): which one do you use to process a million rows, and why?
IntermediateEloquent Performance
Answer
All four exist to stop you calling ->get() on a huge table and exhausting PHP's memory_limit, but they behave differently and one of them is quietly dangerous. chunk(1000, $callback) pages with LIMIT and OFFSET. If the callback mutates a column that appears in the query's where clause, the result set shifts under you between pages and rows get skipped entirely. Updating status where status = 'pending' inside a chunk() over pending rows will silently process roughly half of them, and this bug reaches production regularly because it looks correct and the loop completes without error. chunkById() fixes it by ordering on the primary key and using where id > lastSeenId instead of an offset, so mutations cannot shift the window, and it is also faster on large tables because deep OFFSET forces the database to count and discard rows. cursor() returns a LazyCollection driven by a PHP generator, hydrating exactly one model at a time, which keeps Eloquent's memory flat.
The caveat interviewers look for: with the default buffered MySQL driver the entire result set is still pulled into PHP by the client library, so cursor() on ten million rows can still exhaust memory even though only one model exists at a time. lazy() and lazyById() give you the LazyCollection ergonomics of cursor() with the chunked fetching of chunk(), which is the safest general answer. For write-heavy batch work, add ->toBase() to skip model hydration, disable the query log with DB::disableQueryLog(), and unset relations or call gc_collect_cycles() if you accumulate references.
// DANGEROUS: rows get skipped because the where clause shifts
Order::where('status', 'pending')->chunk(1000, function ($orders) {
$orders->each->update(['status' => 'processing']);
});
// SAFE: keyset pagination on the primary key
Order::where('status', 'pending')->chunkById(1000, function ($orders) {
$orders->each->update(['status' => 'processing']);
});
// LazyCollection ergonomics, chunked fetching under the hood
Order::where('created_at', '<', now()->subYear())
->lazyById(2000)
->each(fn (Order $o) => ArchiveOrder::dispatch($o->id));
// Reporting: no model hydration at all
DB::disableQueryLog();
DB::table('orders')->select('id', 'amount_paise')->orderBy('id')
->lazyById(5000)
->each(fn ($row) => $total += $row->amount_paise);
Key Points
- chunk() uses OFFSET and skips rows when the callback mutates the filter column
- chunkById() uses keyset pagination and is both safe and faster
- cursor() hydrates one model at a time but the driver may still buffer the full result set
- lazyById() is the safe default; toBase() removes hydration cost for reporting
Q20Sanctum, Passport, Fortify and the starter kits: which authentication package do you pick and why?
IntermediateAuthentication
Answer
Sanctum is the default for almost everything in 2026. It does two separate jobs. First, opaque API tokens: createToken() returns a plaintext token whose SHA-256 hash is stored in the personal_access_tokens table, and the auth:sanctum guard authenticates bearer requests against it.
Tokens carry abilities, checked with $user->tokenCan('orders:write') or the abilities middleware, and expiry is configured centrally. Second, SPA cookie authentication: your React or Vue frontend on a listed stateful domain calls /sanctum/csrf-cookie, then logs in normally, and subsequent requests authenticate through the encrypted session cookie with CSRF protection intact, which avoids storing a bearer token in localStorage where XSS can read it. Passport is a full OAuth2 authorization server.
You need it when third parties integrate with your API and need authorization code grants, refresh tokens, scopes and client credentials, for example a partner marketplace or a public developer platform. If nobody outside your organisation is asking for OAuth, Passport is extra tables, extra keys and extra failure modes for no benefit. Fortify is headless authentication: it registers the routes and logic for login, registration, password reset, email verification and two-factor authentication, with no views, so you bring your own frontend.
The starter kits (Breeze historically, and the React, Vue and Livewire kits shipped with Laravel 12) are scaffolding you own after generation, not a dependency. The common production stack in Indian SaaS is Sanctum SPA mode for the web app, Sanctum tokens for mobile and server-to-server integrations, and Fortify underneath when two-factor is required.
// API token with scoped abilities
$token = $user->createToken('mobile-app', ['orders:read', 'orders:write'])
->plainTextToken; // shown once, only the hash is stored
Route::middleware(['auth:sanctum', 'abilities:orders:write'])
->post('/orders', [OrderController::class, 'store']);
// Inside a controller
if (! $request->user()->tokenCan('orders:write')) {
abort(403);
}
// SPA cookie mode: bootstrap/app.php
$middleware->statefulApi();
// .env
// SANCTUM_STATEFUL_DOMAINS=app.example.in,localhost:5173
// SESSION_DOMAIN=.example.in
Q21How do Eloquent API Resources keep a JSON API stable, and what does whenLoaded() prevent?
IntermediateAPI Design
Answer
Returning an Eloquent model directly from a controller serialises whatever columns happen to exist, so adding an internal column to a table silently changes your public API contract, and $hidden becomes the only thing standing between you and leaking a password hash or an internal cost field. An API Resource is an explicit transformation class, created with make:resource OrderResource, whose toArray() maps model state to the exact JSON shape you promise clients. That gives you renamed fields, formatted values (paise to rupees, UTC timestamps to ISO 8601), computed properties and versioned shapes without touching the database layer. whenLoaded('items') is the important one.
If you write 'items' => ItemResource::collection($this->items) the resource accesses the relation, and on a paginated list of fifty orders that fires fifty extra queries from inside the serialisation layer, which is an N+1 that eager loading in the controller was supposed to prevent but that no one notices because it happens after the query builder code. whenLoaded() includes the key only when the relation was already eager loaded and omits it otherwise, so the resource can never trigger a query. The related helpers are when() for conditional fields (an admin-only field, or a value visible only to the owner), whenCounted() for withCount results, additional() to attach meta, and ResourceCollection when the collection itself needs a wrapper. Returning OrderResource::collection($paginator) preserves pagination links and meta automatically. JsonResource::withoutWrapping() removes the default data key if your frontend contract does not want it.
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'reference' => $this->reference,
'amount' => round($this->amount_paise / 100, 2),
'currency' => 'INR',
'status' => $this->status->value,
'placed_at' => $this->created_at->toIso8601String(),
// Never triggers a query
'items' => ItemResource::collection($this->whenLoaded('items')),
'items_count' => $this->whenCounted('items'),
// Only for the owner
'internal_notes' => $this->when(
$request->user()?->is_staff,
fn () => $this->internal_notes,
),
];
}
}
// Controller: eager load once, resource stays query-free
$orders = Order::with('items')->withCount('items')->paginate(20);
return OrderResource::collection($orders);
Key Points
- Resources make the API contract explicit instead of mirroring table columns
- whenLoaded() stops the serialisation layer from firing lazy queries
- when(), whenCounted() and additional() cover conditional and meta fields
- collection() on a paginator preserves links and meta automatically
Q22Why does a queue worker keep running old code after a deploy, and how do you manage worker lifecycle in production?
IntermediateQueues
Answer
php artisan queue:work boots the framework once and then loops forever, so the PHP process holds your application code, your container bindings and your cached config in memory from the moment it started. Deploying new code changes the files on disk but the running worker never re-reads them, which is why a bug you just fixed keeps happening in queued jobs and only in queued jobs. The fix is php artisan queue:restart, which writes a timestamp to the cache; every worker checks it between jobs and exits gracefully once it is newer than its own start time, after which Supervisor or your orchestrator starts a fresh process with the new code.
This command must run in your deploy script after the new release is live and after optimize, and with Horizon the equivalent is horizon:terminate. Memory is the second lifecycle concern. Long-lived PHP processes accumulate memory through static caches, resolved singletons, the query log and retained model references, so you bound them: --memory=256 exits when the process crosses the limit, --max-jobs=1000 exits after a job count, and --max-time=3600 exits after an hour.
Each exit is graceful and the supervisor restarts immediately, which converts a slow leak into a harmless restart cycle. Other production details: --sleep controls polling when the queue is empty, --backoff sets the delay after a failure, --stop-when-empty is what you use in a short-lived container, and a SIGTERM during a Kubernetes rollout triggers a graceful shutdown only if your terminationGracePeriodSeconds exceeds the job timeout. queue:listen restarts the framework per job so it always sees fresh code, but it is far slower and belongs only in local development.
# Supervisor program (production)
[program:laravel-worker]
command=php /var/www/app/artisan queue:work redis --queue=payments,default \
--tries=3 --timeout=60 --memory=256 --max-jobs=1000 --max-time=3600
numprocs=8
autorestart=true
stopwaitsecs=90 # must exceed --timeout
# Deploy script order matters
git pull --ff-only
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize
php artisan queue:restart # workers exit and respawn with new code
# Horizon equivalent
php artisan horizon:terminate
Q23How do you make a queued job idempotent when the queue guarantees at-least-once delivery?
IntermediateQueues
Answer
Every queue driver Laravel supports gives at-least-once delivery, not exactly-once. A worker that is killed after doing its work but before acknowledging, a job that exceeds retry_after while still running, or an automatic retry after a transient failure will all execute your handler a second time. If that handler charges a card, sends an SMS or increments a balance, duplicates are a real financial incident, so idempotency is the job's responsibility.
Laravel gives you three layers. ShouldBeUnique with a uniqueId() prevents a duplicate job from being queued at all, using an atomic cache lock held until the job completes, with uniqueFor() bounding the lock duration; the variant ShouldBeUniqueUntilProcessing releases the lock when the handler starts rather than when it finishes. The WithoutOverlapping job middleware keyed on a resource id serialises jobs touching the same entity, with releaseAfter() to requeue instead of dropping and expireAfter() so a crashed worker cannot hold the lock forever.
Both of these need a cache driver that supports atomic locks, which means Redis, Memcached, DynamoDB or the database driver, not the file driver. Neither is a guarantee, because a lock can expire mid-job. The actual guarantee is at the database level: a unique constraint on an idempotency key, or a conditional update that only applies when the row is still in the expected state, so the second execution is a no-op rather than a second side effect.
For inbound webhooks, store the provider's event id in a unique column and drop replays. The complete answer names the framework helpers as optimisation and the database constraint as correctness.
class CapturePayment implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $uniqueFor = 300;
public function __construct(public string $orderId) {}
public function uniqueId(): string
{
return $this->orderId;
}
public function middleware(): array
{
return [(new WithoutOverlapping($this->orderId))->releaseAfter(10)->expireAfter(120)];
}
public function handle(PaymentGateway $gateway): void
{
// Correctness lives here, not in the lock: conditional update
$claimed = Order::where('id', $this->orderId)
->where('status', 'pending')
->update(['status' => 'capturing']);
if ($claimed === 0) {
return; // another attempt already claimed it
}
$gateway->capture($this->orderId, idempotencyKey: 'cap_' . $this->orderId);
}
}
Key Points
- All Laravel queue drivers are at-least-once; duplicates are expected, not exceptional
- ShouldBeUnique and WithoutOverlapping need an atomic lock driver (Redis, not file)
- Locks reduce duplicates; a unique constraint or conditional update prevents them
- Pass an idempotency key to the payment provider as well
Q24How do job retries, backoff and failure handling work, and what does failed() give you?
IntermediateQueues
Answer
Retry behaviour is set either on the worker (--tries=3) or, better, per job with the $tries property, because a payment capture and a thumbnail generator should not share a retry policy. $backoff can be a single number of seconds or an array such as [10, 60, 300], which gives exponential-style spacing so a struggling upstream API is not hammered; returning an array from a backoff() method lets you compute it dynamically. retryUntil() replaces a count with a deadline, which is the right model for time-sensitive work: retry as often as you like for the next fifteen minutes, then stop. $maxExceptions is a subtler control, it caps unhandled exceptions while allowing many more attempts, which pairs well with jobs that call release() to voluntarily requeue themselves while waiting on an external state change. Calling $this->release(30) puts the job back on the queue after thirty seconds and counts as an attempt. When attempts are exhausted or fail() is called explicitly, the payload plus the exception land in failed_jobs, and the job's failed(Throwable $e) hook runs, which is where you mark the domain record as failed, notify a Slack channel, or emit a metric.
That hook runs after the final attempt only, and importantly it runs in a fresh process, so instance state mutated during handle() is gone. Two more useful flags: $deleteWhenMissingModels = true stops a job from failing loudly when a serialised model was deleted before the job ran, and $timeout kills a job that hangs, though a timeout kill via SIGALRM means failed() may not run unless you also set $failOnTimeout = true.
class SyncGstInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $maxExceptions = 2;
public int $timeout = 30;
public bool $failOnTimeout = true;
public bool $deleteWhenMissingModels = true;
public function backoff(): array
{
return [10, 60, 300, 900];
}
public function retryUntil(): DateTime
{
return now()->addHours(6);
}
public function handle(GstClient $gst): void
{
$response = $gst->push($this->invoice);
if ($response->status() === 429) {
$this->release(60); // upstream throttled us; try later
return;
}
}
public function failed(Throwable $e): void
{
$this->invoice->update(['gst_sync_status' => 'failed']);
Log::error('GST sync failed', ['invoice' => $this->invoice->id, 'error' => $e->getMessage()]);
}
}
Q25What does DB::transaction() actually do with its second argument, and why do jobs dispatched inside a transaction sometimes fail?
IntermediateDatabase
Answer
DB::transaction(Closure $callback, int $attempts = 1) wraps the closure in BEGIN and COMMIT, rolls back on any exception, and re-throws. The second argument is the retry count, and it is not a general-purpose retry: Laravel only retries when the driver reports a deadlock, and on the final attempt it gives up and throws. Passing 3 or 5 is the standard defence for a hot table where two requests update the same rows in different orders.
Manual control is DB::beginTransaction(), DB::commit(), DB::rollBack(), which you need when the boundary spans more than one function, but you then own the try/finally discipline. The classic production bug is dispatching a queued job inside a transaction. Redis and SQS do not participate in your MySQL transaction, so the job can be picked up by a worker microseconds later, before COMMIT lands, and the worker queries a row that does not exist yet, which shows up as an intermittent ModelNotFoundException that never reproduces locally because your local worker is slower than production.
The fixes are the afterCommit() method on the pending dispatch, the $afterCommit = true property on the job class, or setting 'after_commit' => true on the queue connection in config/queue.php so every dispatch waits by default. The same rule applies to events, notifications and mail. A second subtlety: DDL statements such as ALTER TABLE cause an implicit commit in MySQL, so a migration that mixes schema changes with data changes inside a transaction does not roll back the way you expect.
use Illuminate\Support\Facades\DB;
// Retries up to 5 times, but only on deadlock
DB::transaction(function () use ($order) {
$order->update(['status' => 'paid']);
Inventory::where('sku', $order->sku)->decrement('stock', $order->qty);
// Without afterCommit() the worker may run before COMMIT
SendInvoiceMail::dispatch($order)->afterCommit();
}, 5);
// Or make it the default for the job class
class SendInvoiceMail implements ShouldQueue
{
public bool $afterCommit = true;
}
// Or globally in config/queue.php
'redis' => [
'driver' => 'redis',
'after_commit' => true,
],
Key Points
- The second argument to DB::transaction() retries deadlocks only
- Queued jobs, events and mail can fire before COMMIT
- Fix with afterCommit(), $afterCommit = true, or after_commit in config/queue.php
- DDL statements implicitly commit in MySQL and break rollback expectations
Q26Two requests debit the same wallet at the same time. How do you stop the balance going negative in Laravel?
IntermediateConcurrency
Answer
This is the question that separates people who have shipped payments from people who have not. Reading the balance, checking it in PHP, then writing the new value is a read-modify-write race: both requests read 500, both decide 400 is affordable, both write 100, and you have paid out 800 from a 500 balance. PHP-FPM runs each request in a separate process, so nothing in the language protects you.
There are three real fixes, and a good answer names the trade-offs. First, pessimistic locking: select the row inside a transaction with lockForUpdate(), which issues SELECT ... FOR UPDATE and makes the second request block until the first commits. sharedLock() issues LOCK IN SHARE MODE, which permits concurrent reads but blocks writers, and is usually not what you want for a debit.
Second, an atomic conditional update: a single UPDATE wallets SET balance = balance - ? WHERE id = ? AND balance >= ?, then check the affected row count, zero rows means insufficient funds.
This is the cheapest option because it holds no application-level state and the database does the comparison. Third, an application-level lock via Cache::lock('wallet:'.$id, 10)->block(5, fn () => ...), which works across servers when backed by Redis but not with the file cache driver. Add a CHECK constraint or an unsigned column as a last line of defence so a bug cannot physically write a negative balance. Interviewers will also probe transaction isolation: MySQL defaults to REPEATABLE READ, so a plain SELECT inside a transaction reads a snapshot and will not see another session's committed change, which is precisely why the read must be a locking read.
use Illuminate\Support\Facades\DB;
// Option 1: pessimistic lock (SELECT ... FOR UPDATE)
DB::transaction(function () use ($walletId, $amount) {
$wallet = Wallet::whereKey($walletId)->lockForUpdate()->firstOrFail();
if ($wallet->balance < $amount) {
throw new InsufficientFundsException();
}
$wallet->decrement('balance', $amount);
}, 3);
// Option 2: atomic conditional update, no lock held in PHP
$rows = Wallet::whereKey($walletId)
->where('balance', '>=', $amount)
->update(['balance' => DB::raw('balance - ' . (int) $amount)]);
if ($rows === 0) {
throw new InsufficientFundsException();
}
// Option 3: distributed lock (Redis cache driver required)
Cache::lock('wallet:' . $walletId, 10)->block(5, function () use ($walletId, $amount) {
// critical section
});
Key Points
- Read-modify-write in PHP is never safe across concurrent FPM processes
- lockForUpdate() issues SELECT ... FOR UPDATE inside a transaction
- A conditional UPDATE with an affected-rows check is the cheapest fix
- Cache::lock() needs Redis or Memcached, never the file driver
- Back it with a database constraint so a bug cannot persist a negative balance
Q27Which Eloquent model events do NOT fire, and why does that break audit logging?
IntermediateEloquent
Answer
Eloquent fires retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, replicating, trashed and forceDeleted, and you hook them with an observer registered via the ObservedBy attribute on the model or Model::observe() in a service provider. The trap is that these are model events, not database events: they only fire when Eloquent hydrates and saves an individual model instance. Mass operations bypass them entirely.
Post::where('draft', true)->update(['draft' => false]) issues one UPDATE statement and fires no updating or updated event, no observer runs, no updated_at is touched unless you set it yourself. The same is true of ->delete() on a query builder, insert() for bulk inserts, increment() and decrement() (which do fire model events only when called on an instance), upsert(), and anything written through DB::table(). Teams discover this when their audit trail is missing exactly the rows a bulk admin action touched.
The remedies are to loop with chunkById() and save each model when you genuinely need per-row hooks, to move the invariant into a database trigger or a foreign key when correctness matters more than portability, or to emit an explicit domain event from the service method that performed the bulk change. Two more gotchas. Observers on a model with SoftDeletes fire deleting/deleted for the soft delete and forceDeleted for the hard one, so a naive observer double-counts. And an observer that dispatches a queued job should use afterCommit, because saved() fires before the surrounding transaction commits.
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy(InvoiceObserver::class)]
class Invoice extends Model {}
class InvoiceObserver
{
public function updated(Invoice $invoice): void
{
AuditLog::create([
'model' => Invoice::class,
'id' => $invoice->id,
'changes' => $invoice->getChanges(),
'user_id' => auth()->id(),
]);
}
}
// Fires the observer once per row
Invoice::where('status', 'draft')->chunkById(500, function ($invoices) {
$invoices->each->update(['status' => 'issued']);
});
// Fires NOTHING: single UPDATE statement, no observer, no audit row
Invoice::where('status', 'draft')->update(['status' => 'issued']);
// Also silent
Invoice::insert($rows);
DB::table('invoices')->where('id', 7)->delete();
Q28How do cache tags, atomic locks and stale cache behave across Laravel's cache drivers?
IntermediateCaching
Answer
The cache API looks uniform but the drivers are not. Cache::tags(['reports', 'tenant:7'])->put(...) only works on Redis, Memcached, DynamoDB and array; calling it on the file or database driver throws a BadMethodCallException, which is why a feature that works locally with file cache breaks the moment staging switches to Redis or the other way round. Tag flushing is also not free: on Redis, tags are implemented with a set of tag keys plus a shared namespace, and flushing a tag invalidates by rotating the namespace rather than deleting each entry, so orphaned keys linger until they expire.
Atomic locks (Cache::lock) require Redis, Memcached, DynamoDB, database or array; the file driver has no atomic primitive. Use get(Closure) for a non-blocking attempt, block($seconds) to wait, and owner()/restoreLock() when you need to acquire in a web request and release inside a queued job. Always pass a lock TTL so a crashed process cannot hold the lock forever.
Cache stampede is the failure mode most candidates miss: when a hot key expires under load, every concurrent request misses at once and all of them run the expensive callback. Cache::remember() gives no protection. Recent Laravel versions ship Cache::flexible($key, [$fresh, $stale], $callback), which serves the stale value and refreshes it in a deferred background task once you pass the first threshold, which is the built-in stale-while-revalidate answer.
Before that, the standard workaround was remember() plus a short Cache::lock so only one process recomputes. Also remember that Cache::forever() is not really forever, Redis will evict it under the allkeys-lru policy.
// Tags: Redis / Memcached / DynamoDB only
Cache::tags(['reports', 'tenant:' . $tenantId])
->remember('mrr', 3600, fn () => $this->computeMrr($tenantId));
Cache::tags(['tenant:' . $tenantId])->flush();
// Atomic lock with an owner token so a job can release it later
$lock = Cache::lock('payout-run', 300);
if ($lock->get()) {
ProcessPayouts::dispatch($lock->owner());
}
// Inside the job
Cache::restoreLock('payout-run', $this->owner)->release();
// Stale-while-revalidate: fresh for 5 min, usable up to 30 min
$stats = Cache::flexible('dashboard:stats', [300, 1800], function () {
return DB::table('orders')->selectRaw('count(*) c, sum(total) t')->first();
});
Key Points
- Tags fail on the file and database drivers with BadMethodCallException
- Cache::lock() needs an atomic driver and always needs a TTL
- remember() offers no stampede protection; flexible() or a lock does
- Cache::forever() is still evictable under Redis LRU
Q29How does the Laravel scheduler run, and how do you stop a task firing twice on a multi-server deployment?
IntermediateScheduling
Answer
Laravel does not schedule anything by itself. You register one system cron entry that runs php artisan schedule:run every minute; that command boots the app, compares each defined task against the current minute, and runs whatever is due. In Laravel 11 and later the definitions live in routes/console.php using Schedule::command('reports:daily')->dailyAt('02:00'), or in the withSchedule() callback in bootstrap/app.php, replacing the old Kernel::schedule() method.
Timezone matters for Indian products: config('app.timezone') is usually UTC, so ->dailyAt('02:00') means 07:30 IST unless you chain ->timezone('Asia/Kolkata'), and interviewers do ask this. On a single server, ->withoutOverlapping() prevents a slow task from stacking on itself; it takes an atomic cache lock and by default expires after 24 hours, so pass ->withoutOverlapping(10) when the task can legitimately die mid-run. On multiple app servers, every box runs the same cron, so a daily invoice job fires three times on a three-node cluster. ->onOneServer() fixes that by taking a lock in a shared cache store, which means Redis, Memcached or DynamoDB, not the file driver, and the servers must share one store.
Also useful: ->runInBackground() so a long task does not delay the rest of the schedule, ->evenInMaintenanceMode(), ->when()/->skip() for conditional runs, ->emailOutputOnFailure(), and ->onSuccess()/->onFailure() hooks. Verify with php artisan schedule:list, which prints every task with its next due time, and test locally with php artisan schedule:work, which runs a foreground loop instead of relying on cron. In containers, run schedule:work in its own sidecar rather than installing cron in the image.
// routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;
Schedule::command('invoices:generate')
->dailyAt('02:00')
->timezone('Asia/Kolkata')
->onOneServer() // needs a shared Redis/Memcached store
->withoutOverlapping(30)
->runInBackground()
->emailOutputOnFailure('ops@example.com');
Schedule::job(new ReconcileSettlements)->hourly();
Schedule::call(fn () => Cache::forget('homepage'))->everyFiveMinutes();
// The single crontab entry that drives all of it
// * * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Q30What is the difference between an event listener, a queued listener and a subscriber, and when do events make a codebase worse?
IntermediateEvents
Answer
An event is a plain PHP object describing something that happened; a listener handles it. Since Laravel 11, listeners in app/Listeners are auto-discovered from their handle() type hint, so the EventServiceProvider $listen array is optional, though you can still register manually with Event::listen() or the AsListener style attributes. By default listeners run synchronously and in registration order, inside the same request, so a slow listener slows the response and a thrown exception aborts the whole request.
Implement ShouldQueue on the listener and the framework serialises the event and pushes it to the queue instead, which is how you keep a signup request fast while still sending a welcome email. Queued listeners get the same controls as jobs: $tries, $backoff, $queue, a failed() hook, and shouldQueue() for conditional queueing. They also get the same serialisation trap, the event is serialised, so an event carrying an unserialisable closure or a huge collection blows up at dispatch time, and models are stored by id and re-fetched.
A subscriber is one class with a subscribe(Dispatcher $events) method that registers several handlers at once, useful when a dozen related listeners would otherwise clutter discovery. Where events hurt: control flow becomes invisible. If your order-placement path fires eight events and each has three listeners, no one can read the code and know what happens.
The pattern that scales is to keep the synchronous, must-succeed steps in an explicit action or service class and use events only for genuinely optional side effects such as analytics, notifications and cache warming. Interviewers often ask how you test this: Event::fake() lets you assert Event::assertDispatched(OrderPlaced::class) without running listeners.
class OrderPlaced
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}
class SendOrderConfirmation implements ShouldQueue
{
public int $tries = 3;
public string $queue = 'mail';
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->user)->send(new OrderConfirmation($event->order));
}
public function shouldQueue(OrderPlaced $event): bool
{
return $event->order->total > 0;
}
}
OrderPlaced::dispatch($order);
// In a test
Event::fake([OrderPlaced::class]);
$this->post('/orders', $payload);
Event::assertDispatched(OrderPlaced::class, fn ($e) => $e->order->total === 4999);
Key Points
- Listeners are synchronous unless they implement ShouldQueue
- Laravel 11+ auto-discovers listeners from the handle() type hint
- Queued listeners inherit job semantics: tries, backoff, failed()
- Keep must-succeed steps explicit; use events for optional side effects
Q31How do you test a Laravel app properly: RefreshDatabase versus DatabaseTransactions, and what do the fakes give you?
IntermediateTesting
Answer
Laravel ships with both PHPUnit and Pest wired up, and since Laravel 11 new installs default to Pest. Feature tests boot the whole framework and hit routes through the kernel, so $this->postJson('/api/orders', $payload)->assertStatus(201)->assertJsonPath('data.status', 'pending') exercises middleware, validation, controller and serialisation in one call. Unit tests skip the framework and are worth writing only for genuinely pure logic.
The database traits matter. RefreshDatabase runs your migrations once and wraps each test in a transaction that is rolled back afterwards, which is fast and the right default. DatabaseTransactions only wraps in a transaction and assumes the schema already exists, useful against a pre-migrated test database.
DatabaseMigrations runs migrate:fresh for every single test, which is correct but slow enough that a large suite takes minutes. Use RefreshDatabase with a dedicated MySQL or Postgres test database rather than SQLite in memory, because SQLite silently accepts things MySQL rejects, notably strict-mode violations and certain ALTER operations, and you want the test suite to fail where production would. The fakes are the other half: Queue::fake() and Bus::fake() stop jobs executing and let you assert Queue::assertPushed(ProcessRefund::class), Mail::fake() with Mail::assertQueued(), Notification::fake(), Storage::fake('s3'), Event::fake(), and Http::fake(['api.razorpay.com/*' => Http::response(['id' => 'pay_1'], 200)]) so no test ever hits a real network.
Http::preventStrayRequests() makes an unfaked outbound call throw instead of quietly succeeding. Time-dependent logic is handled with $this->travelTo(now()->addDays(8)) and $this->freezeTime(). Interviewers commonly ask about factory states and about asserting database side effects with assertDatabaseHas().
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('captures a payment and queues the invoice', function () {
Queue::fake();
Http::preventStrayRequests();
Http::fake(['api.razorpay.com/*' => Http::response(['id' => 'pay_9x'], 200)]);
$user = User::factory()->has(Wallet::factory()->state(['balance' => 5000]))->create();
$this->actingAs($user)
->postJson('/api/payments', ['amount' => 4999])
->assertCreated()
->assertJsonPath('data.gateway_id', 'pay_9x');
$this->assertDatabaseHas('payments', ['user_id' => $user->id, 'amount' => 4999]);
Queue::assertPushed(GenerateInvoice::class, fn ($job) => $job->amount === 4999);
});
Key Points
- RefreshDatabase is the fast default; DatabaseMigrations is correct but slow
- Test against the same database engine you run in production, not SQLite
- Queue, Mail, Notification, Storage, Event and Http fakes isolate side effects
- Http::preventStrayRequests() turns an unmocked outbound call into a failure
Q32How does Laravel's filesystem abstraction work, and how do you serve a private S3 file without proxying it through PHP?
IntermediateFile Storage
Answer
config/filesystems.php defines named disks over Flysystem: local (storage/app), public (storage/app/public), s3, and anything else you add. Storage::disk('s3')->put($path, $contents) writes; Storage::disk('s3')->putFileAs() takes an UploadedFile. The public disk is not web-accessible until you run php artisan storage:link, which symlinks public/storage to storage/app/public, and the missing symlink after a deploy is the single most common broken-images bug in Laravel deployments, because most deploy scripts forget it on a fresh release directory.
For genuinely private files, do not stream through PHP. Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(5)) returns a presigned URL that S3 serves directly, so a 50 MB PDF never occupies an FPM worker. For the local disk the equivalent is a signed route created with URL::temporarySignedRoute() plus the signed middleware, which appends a hash of the URL and expiry so the link cannot be tampered with.
When you must stream, use response()->streamDownload() or Storage::download() so memory usage stays flat instead of loading the whole file into a string. Uploads deserve their own care: validate with the file, mimes, mimetypes and max rules, never trust the client-supplied filename, generate your own with hashName() or a UUID, and store the original name in a column if you need it. For large uploads, take them straight to S3 from the browser with a presigned POST so the request never touches your app servers. If the deploy runs behind a CDN, remember that temporaryUrl signatures are bound to the S3 host, so you need CloudFront signed URLs instead of S3 presigning when you front the bucket.
// config/filesystems.php disk usage
$path = Storage::disk('s3')->putFileAs(
'invoices/' . $user->id,
$request->file('pdf'),
Str::uuid() . '.pdf',
'private'
);
// Presigned URL: S3 serves the bytes, PHP does not
$url = Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(5));
// Signed route for the local disk
$url = URL::temporarySignedRoute('invoice.download', now()->addMinutes(5), ['invoice' => $id]);
Route::get('/invoices/{invoice}/download', DownloadInvoice::class)
->name('invoice.download')
->middleware('signed');
// Constant-memory streaming when you really must proxy
return response()->streamDownload(function () use ($path) {
$stream = Storage::disk('s3')->readStream($path);
fpassthru($stream);
}, 'invoice.pdf');
Key Points
- Disks are Flysystem adapters configured in config/filesystems.php
- storage:link must run on every fresh release directory
- temporaryUrl() presigns S3 so PHP never streams the bytes
- Signed routes plus the signed middleware are the local-disk equivalent
Q33How do you rate limit an API in Laravel, and how do you give different limits to free and paid users?
IntermediateAPI Design
Answer
The throttle middleware is backed by the RateLimiter facade. You define named limiters in a service provider with RateLimiter::for('api', fn (Request $r) => Limit::perMinute(60)->by($r->user()?->id ?: $r->ip())), then apply them as throttle:api on routes or route groups. The by() key is what decides whose bucket is consumed, and getting it wrong is a real incident: keying purely on IP means every user behind one corporate NAT or one mobile carrier gateway shares a bucket, which in India can mean thousands of users on a single Jio or Airtel egress IP hitting a 429 together.
Key on the authenticated user id when you have one and fall back to IP only for guests. Returning an array of Limit objects applies several limits at once, for example a per-minute burst limit and a per-day quota. Limit::none() exempts a tier entirely, and response() lets you customise the 429 body so clients get a machine-readable error rather than Laravel's default.
The middleware sets X-RateLimit-Limit, X-RateLimit-Remaining and, on rejection, Retry-After and X-RateLimit-Reset, which well-behaved clients honour. Storage matters: the limiter uses the cache, so on multiple app servers it must be Redis or the counters are per-node and your effective limit is silently multiplied by the node count. throttle:60,1 without a named limiter still works for simple cases, and throttleWithRedis() uses a Lua script for exact atomic counting under heavy concurrency. Beyond the framework, put a coarse limit at the edge (Cloudflare, ALB, nginx limit_req) so abusive traffic never boots PHP at all, and keep the Laravel limiter for per-endpoint business rules.
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
// AppServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if ($user?->onPlan('enterprise')) {
return Limit::none();
}
return [
Limit::perMinute($user?->plan_rpm ?? 30)->by($user?->id ?: $request->ip()),
Limit::perDay(10000)->by($user?->id ?: $request->ip()),
];
});
RateLimiter::for('otp', fn (Request $r) => Limit::perMinute(3)
->by($r->input('phone'))
->response(fn () => response()->json(['error' => 'otp_throttled'], 429)));
// routes/api.php
Route::middleware('throttle:api')->group(function () {
Route::post('/search', SearchController::class);
});
Q34When do you drop to the query builder or raw SQL in Laravel, and how do you keep it injection-safe?
IntermediateDatabase
Answer
Eloquent is a productivity layer over the query builder, and the query builder is a thin layer over PDO. Drop to the query builder when you are doing set-based work with no need for model instances: reporting aggregates, bulk updates, joins across five tables, window functions. DB::table('orders')->selectRaw('date(created_at) d, sum(total) t')->groupBy('d')->get() returns stdClass rows with no hydration cost, which on a hundred thousand rows is dramatically cheaper than hydrating a hundred thousand Eloquent models.
The safety rule is simple and interviewers test it directly: anything with Raw in the name (selectRaw, whereRaw, havingRaw, orderByRaw, DB::raw, DB::statement) does not escape its content, so never interpolate user input into that string. Pass bindings as the second argument instead, whereRaw('total > ?', [$min]), and the driver parameterises it. The subtle one is orderByRaw($request->sort), which cannot be parameterised at all because column names and directions are not bindable in prepared statements; the only safe approach is an allowlist match against known column names.
A second, less obvious risk is passing a raw expression as a column name to where(), because the builder trusts column identifiers. Other practical notes: DB::select() returns arrays and does not go through Eloquent, so casts, accessors and global scopes do not apply; DB::listen() or Laravel Pulse will show you the actual SQL when you are unsure what the builder produced; and toSql() plus getBindings() during development is faster than guessing. For genuinely complex reporting, a database view or a stored query in a repository class usually reads better than a 40-line builder chain.
// Safe: bindings passed separately
$rows = DB::table('orders')
->selectRaw('date(created_at) as d, sum(total) as revenue')
->whereRaw('total > ?', [$minimum])
->whereBetween('created_at', [$from, $to])
->groupBy('d')
->orderBy('d')
->get();
// UNSAFE: user input interpolated into raw SQL
$rows = DB::table('orders')->whereRaw("status = '{$request->status}'")->get();
// Sort columns cannot be bound, so allowlist them
$sortable = ['created_at', 'total', 'status'];
$column = in_array($request->sort, $sortable, true) ? $request->sort : 'created_at';
$direction = $request->dir === 'asc' ? 'asc' : 'desc';
$orders = Order::orderBy($column, $direction)->paginate(25);
// Inspect what the builder actually produced
logger(Order::whereActive(true)->toSql(), Order::whereActive(true)->getBindings());
Key Points
- Query builder avoids model hydration cost on large result sets
- Every *Raw method trusts its string; pass bindings as the second argument
- Column names and sort directions cannot be bound, so allowlist them
- DB::select() skips casts, accessors and global scopes
Q35How do queued mailables and notifications work, and what breaks when the mail provider throttles you?
IntermediateMail and Notifications
Answer
A Mailable becomes queued the moment it implements ShouldQueue, or when you call Mail::to($user)->queue($mailable) instead of send(). Queueing is the correct default in any request path, because an SMTP handshake in a controller adds hundreds of milliseconds and fails the user's request when the provider hiccups. Notifications go a level higher: one Notification class declares via() returning any mix of channels, mail, database, broadcast, slack, vonage, plus community channels for WhatsApp or SMS providers that Indian teams commonly wire in.
Implement ShouldQueue on the notification and every channel dispatch is queued independently. Notification::route('mail', $address) sends on-demand to someone who is not a User model. The database channel writes to a notifications table you create with php artisan make:notifications-table, which is what powers in-app bells.
Now the failure modes. Providers throttle: SES enforces a per-second send rate, and a Horizon supervisor with 20 workers will blow straight through it and start collecting 454 throttling errors into failed_jobs. The framework answer is Redis-backed throttling on the job (Redis::throttle('mail')->allow(10)->every(1)) or the middleware form via WithoutOverlapping and RateLimited middleware returned from the job's middleware() method.
Second, mail rendering happens in the worker, so a Blade error in the template surfaces as a failed job, not a 500, and nobody notices unless you monitor failed_jobs. Third, serialised notifiables use SerializesModels, so a user deleted between dispatch and execution throws ModelNotFoundException unless you set $deleteWhenMissingModels. Always route transactional mail to a dedicated queue so a marketing blast cannot starve password resets.
use Illuminate\Queue\Middleware\RateLimited;
class InvoiceIssued extends Notification implements ShouldQueue
{
use Queueable;
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function viaQueues(): array
{
return ['mail' => 'transactional', 'database' => 'default'];
}
public function middleware(): array
{
return [new RateLimited('ses')];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Invoice ' . $this->invoice->number)
->line('Amount due: ₹' . number_format($this->invoice->total, 2))
->action('View invoice', route('invoices.show', $this->invoice));
}
}
// AppServiceProvider::boot()
RateLimiter::for('ses', fn () => Limit::perSecond(10));
Key Points
- ShouldQueue on a Mailable or Notification moves sending off the request
- via() fans one notification out across mail, database, broadcast and SMS
- Provider rate limits need RateLimited middleware or Redis::throttle
- Give transactional mail its own queue so bulk sends cannot starve it
Q36How does real-time broadcasting work in Laravel with Reverb, and how are private channels authorised?
IntermediateBroadcasting
Answer
Broadcasting pushes server-side events to connected browsers over WebSockets. An event implements ShouldBroadcast (queued) or ShouldBroadcastNow (synchronous), returns one or more channels from broadcastOn(), and optionally overrides broadcastAs() for the wire name and broadcastWith() for the payload. The transport is a driver: Reverb is Laravel's first-party self-hosted WebSocket server, started with php artisan reverb:start, and it speaks the Pusher protocol, so Pusher Channels, Ably and Soketi are drop-in alternatives and the front-end code stays identical with Laravel Echo.
Self-hosting matters for Indian teams watching costs, because Pusher pricing is per-connection and Reverb on a single VM handles a large number of concurrent connections. There are three channel types. Public channels need no authorisation.
Private channels (prefix private-) and presence channels (presence-, which also tracks who is online) require the client to hit an authorisation endpoint, /broadcasting/auth, before subscribing. You define the rule in routes/channels.php with Broadcast::channel('orders.{orderId}', fn (User $user, int $orderId) => Order::find($orderId)?->user_id === $user->id), and returning false or null denies the subscription. This is the security boundary that candidates skip, and a public channel carrying order data is a straightforward data leak.
Two production notes. ShouldBroadcast dispatches through the queue, so if no worker is running your events silently never arrive, which produces the classic 'it works locally' bug because local dev often uses the sync queue driver. And broadcastWith() should send an id, not the whole model, because payloads are visible to anyone subscribed to that channel and models drift as you add columns.
class OrderShipped implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('orders.' . $this->order->id)];
}
public function broadcastAs(): string
{
return 'order.shipped';
}
public function broadcastWith(): array
{
return ['id' => $this->order->id, 'status' => $this->order->status];
}
}
// routes/channels.php
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
return Order::whereKey($orderId)->value('user_id') === $user->id;
});
// Front end with Laravel Echo
// Echo.private(`orders.${id}`).listen('.order.shipped', (e) => update(e));
Q37Which perfectly normal Laravel code breaks the moment you put the app behind Octane?
AdvancedOctane
Answer
Octane boots the framework once and then serves many requests inside the same long-lived worker on Swoole, Open Swoole or FrankenPHP. That single change invalidates the assumption every Laravel developer has internalised, that the process dies after each request and cleans up your mess. Four categories break.
First, singletons registered in register() are resolved once and then shared by every subsequent request in that worker, so a singleton that stores the current user, the current tenant, or a request-scoped locale leaks that state to the next visitor. The fix is to bind such services with scoped() rather than singleton(), because Octane flushes scoped instances between requests. Second, static properties and static caches on your own classes persist for the worker's lifetime; a static $config = null lazy cache never gets a second chance to reload.
Third, container state captured at boot goes stale: injecting Request into a singleton's constructor freezes the first request forever, so resolve the request per call or use Octane's listeners. Fourth, memory leaks that were previously invisible now matter, an ever-growing static array or an unbounded in-memory cache that used to vanish at end of request now grows until the worker is recycled, which is why you set --max-requests and watch RSS. Octane also gives you real tools: Octane::concurrently() to run independent I/O tasks in parallel, Octane::tick() for periodic work inside the worker, and a per-worker Octane cache backed by a shared memory table that is dramatically faster than Redis for hot read-mostly data. Run php artisan octane:status and reload with octane:reload after deploys, and test under Octane in CI, because the bugs are load-dependent and never show up in a single manual click-through.
// Leaks across requests under Octane
$this->app->singleton(TenantContext::class); // WRONG
$this->app->scoped(TenantContext::class); // flushed per request
// Static caches survive the request
class FeatureFlags
{
private static ?array $flags = null; // never reloads under Octane
public static function all(): array
{
return self::$flags ??= DB::table('flags')->pluck('enabled', 'key')->all();
}
}
// Reset your own state between requests
Octane::flush(TenantContext::class);
// Parallel I/O inside one request
[$plans, $usage] = Octane::concurrently([
fn () => Http::get('https://billing.internal/plans')->json(),
fn () => Http::get('https://metering.internal/usage')->json(),
]);
// Deploy: recycle workers so they pick up new code
// php artisan octane:reload
Key Points
- Workers persist, so singleton() state leaks between users
- Use scoped() for per-request services and Octane::flush() for your own
- Static properties and injected Request objects go stale
- Set --max-requests, monitor RSS, and run octane:reload on deploy
Q38How do you run queues at scale with Horizon, and how do you choose between the redis, sqs and database drivers?
AdvancedQueues at Scale
Answer
Horizon is a Redis-only supervisor and dashboard. config/horizon.php defines environments and supervisors, each with a queue list, a balance strategy, process counts and per-process limits, and php artisan horizon runs them under a process manager such as Supervisor or systemd. The balance strategies matter: false runs a fixed process count per queue, simple splits processes evenly, and auto (the usual choice) shifts workers toward busy queues based on wait time, bounded by minProcesses and maxProcesses, with autoScalingStrategy set to time or size. Give latency-sensitive queues their own supervisor so a 40-minute report export cannot delay OTP delivery, and set waits thresholds so Horizon emits a LongWaitDetected event you can alert on.
Per-supervisor memory and timeout limits stop a leaky job taking the box down, and tries plus balanceMaxShift tune responsiveness. Driver choice comes down to three trade-offs. Redis is fastest, supports Horizon, delayed jobs, priorities and batches, but the queue lives in memory so you need persistence configured (AOF) and enough RAM, and a Redis failure loses queued work if you have not planned for it.
SQS is fully managed and durable with effectively unlimited depth, which is attractive if the rest of the stack is on AWS, but the 15-minute maximum delay, the 256 KB message limit, at-least-once delivery with visibility timeouts, and no Horizon dashboard are real constraints. The database driver is fine for a small app or a single server and terrible under contention, because every worker polls the same table with FOR UPDATE SKIP LOCKED and you end up putting queue load on your primary database. Whatever you pick, monitor failed_jobs depth and queue wait time as first-class alerts.
// config/horizon.php
'environments' => [
'production' => [
'payments' => [
'connection' => 'redis',
'queue' => ['payments', 'webhooks'],
'balance' => 'auto',
'minProcesses' => 3,
'maxProcesses' => 20,
'balanceMaxShift' => 3,
'tries' => 5,
'timeout' => 60,
'memory' => 256,
],
'reports' => [
'connection' => 'redis',
'queue' => ['exports'],
'balance' => 'simple',
'maxProcesses' => 4,
'timeout' => 3600,
'memory' => 1024,
],
],
],
'waits' => ['redis:payments' => 30],
// Alert on it
Event::listen(LongWaitDetected::class, fn ($e) => Log::critical('queue backlog', [$e->queue]));
Key Points
- Horizon requires Redis; SQS and database queues get no dashboard
- Separate supervisors keep slow exports away from latency-sensitive queues
- balance auto scales workers by wait time between min and max processes
- SQS caps delay at 15 minutes and messages at 256 KB
- The database driver puts queue contention on your primary database
Q39How do job batching and chaining differ, and how would you orchestrate a multi-step import that must report progress?
AdvancedQueues at Scale
Answer
A chain runs jobs strictly in sequence and stops at the first failure, which is what you want for dependent steps: create the record, then call the gateway, then send the receipt. Bus::chain([...])->catch(...)->dispatch() is the API, and any job can append more work with $this->prependToChain() or $this->appendToChain() at runtime. A batch runs jobs in parallel and tracks them as a unit.
Bus::batch($jobs) returns a Batch object with an id you can persist and poll, and it exposes progress(), processedJobs(), failedJobs(), totalJobs, plus the then(), catch() and finally() callbacks. Batches need the job_batches table (php artisan make:queue-batches-table) and every batched job must use the Batchable trait. By default a batch is cancelled on the first failure; allowFailures() lets the rest run and surfaces the failures at the end, which is right for a 50,000-row CSV import where you want the good rows in and a report of the bad ones.
Inside a job, always guard with if ($this->batch()?->cancelled()) return; so a cancelled batch does not keep burning worker time. The scalable import shape is a chain of two steps: first a job that reads the file and dispatches a batch of chunk jobs, then a finally() callback that writes the summary and notifies the user. Store the batch id on your own import record so the front end can poll a status endpoint and render a progress bar from progress(). Two production notes: batch callbacks are serialised closures, so they cannot capture unserialisable state, and $this->batch() is null when a batched job class is dispatched outside a batch, which is why the null-safe operator matters.
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch(
LazyCollection::make(fn () => yield from $rows)
->chunk(500)
->map(fn ($chunk) => new ImportCandidateChunk($chunk->all()))
->all()
)->name('candidate-import:' . $import->id)
->allowFailures()
->onQueue('imports')
->progress(fn (Batch $b) => Cache::put("import:{$b->id}", $b->progress(), 3600))
->finally(function (Batch $b) use ($import) {
$import->update([
'status' => $b->hasFailures() ? 'completed_with_errors' : 'completed',
'failed' => $b->failedJobs,
]);
})->dispatch();
$import->update(['batch_id' => $batch->id]);
class ImportCandidateChunk implements ShouldQueue
{
use Batchable, Queueable;
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
// ... insert rows
}
}
Key Points
- Chains are sequential and abort on failure; batches are parallel and tracked
- Batchable trait plus the job_batches table are required
- allowFailures() keeps the batch running and reports failures at the end
- Guard every batched job with $this->batch()?->cancelled()
Q40Walk through a zero-downtime Laravel deploy. Which optimisation commands run, in what order, and what goes wrong if you get it wrong?
AdvancedDeployment
Answer
The atomic-release model is the standard: clone or copy the new code into releases/TIMESTAMP, run composer install --no-dev --optimize-autoloader and the front-end build there, share storage/ and .env by symlink, warm the caches, then flip the current symlink in one atomic operation and reload PHP-FPM. Envoyer, Deployer and a plain GitHub Actions script all implement this shape. Cache warming order matters: php artisan config:cache first (it merges every config file into one bootstrap/cache/config.php), then route:cache, then view:cache, then event:cache; php artisan optimize runs the set. config:cache is the one that bites, because once config is cached, env() returns null everywhere outside config/, so any service or Blade file calling env() directly starts silently returning null, and a payment key becomes an empty string. route:cache fails outright if any route uses a closure instead of a controller, and the error message points at serialisation, not at your route file.
Migrations need thought: run them before the symlink flip only if they are backwards compatible with the old code still serving traffic, which means additive changes (new nullable columns, new tables) are safe while renames and drops need the expand-migrate-contract pattern across two deploys. After the flip, reload FPM (or run php artisan octane:reload) so OPcache picks up the new paths, and restart queue workers with php artisan queue:restart, because workers hold code in memory and will otherwise keep executing the previous release. php artisan down --secret=... lets you bypass maintenance mode from your own browser while the world sees the maintenance page, and --render pre-renders a Blade view so the page works even mid-deploy. Finally, run php artisan about in CI to confirm the caches you expect are actually active.
# Build the release
composer install --no-dev --prefer-dist --optimize-autoloader
npm ci && npm run build
# Warm caches inside the new release directory
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# or simply: php artisan optimize
# Backwards-compatible migrations before the flip
php artisan migrate --force --isolated
# Atomic switch, then reload runtimes
ln -sfn /var/www/releases/20260811T0930 /var/www/current
sudo systemctl reload php8.3-fpm
php artisan queue:restart
php artisan octane:reload # only if running Octane
# Verify what is actually cached
php artisan about --only=cache
Key Points
- config:cache makes every env() call outside config/ return null
- route:cache fails on closure-based routes
- queue:restart is mandatory or workers keep running the old release
- Use --isolated on migrate so parallel deploy nodes do not race
- Expand-migrate-contract for any destructive schema change
Q41How do you configure read and write database connections in Laravel, and what breaks because of replica lag?
AdvancedScaling
Answer
config/database.php lets a single connection define separate read and write hosts, with sticky => true as the option that decides whether the setup is usable. Laravel then sends SELECT statements to a read host (chosen at random from the list) and writes to the primary. Without sticky, the classic bug appears immediately: a controller inserts a row on the primary and then reads it back through the same connection, the read goes to a replica that has not applied the binlog yet, and you get a 404 or a stale value on your own write. sticky => true tells Laravel that once a write has occurred on a connection during the current request or job, all subsequent reads on that connection use the write host for the remainder of that lifecycle.
That fixes read-your-own-write within one request but not across requests, so a redirect after a POST can still land on a replica and show stale data; the pragmatic fixes are to render from the object you just wrote rather than re-querying, or to force the primary explicitly with DB::connection('mysql::write') or by running the read inside a transaction. Queued jobs are their own trap: a job dispatched during a request runs in a different process with a fresh connection, so sticky does not carry over, which is another reason to dispatch afterCommit and to re-fetch by id inside the job rather than trusting serialised state. Beyond replicas, the levers for a heavy Laravel app are connection pooling (PgBouncer for Postgres, ProxySQL for MySQL) because PHP-FPM opens a connection per worker and can exhaust max_connections long before CPU saturates, per-connection timeouts, and separating reporting traffic onto its own replica so an analytics query cannot lock up checkout.
// config/database.php
'mysql' => [
'driver' => 'mysql',
'read' => [
'host' => ['10.0.1.21', '10.0.1.22'],
],
'write' => [
'host' => ['10.0.1.10'],
],
'sticky' => true, // read-your-own-write within a request
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
],
// Force the primary for a read that must be current
$order = DB::connection('mysql::write')
->table('orders')
->where('id', $id)
->first();
// Reporting traffic on its own connection
$report = DB::connection('mysql_analytics')
->table('orders')
->selectRaw('sum(total) as revenue')
->whereBetween('created_at', [$from, $to])
->value('revenue');
Key Points
- sticky => true routes post-write reads back to the primary for that request
- Sticky does not survive into queued jobs or the next HTTP request
- Redirect-after-POST can still read a lagging replica
- PHP-FPM opens a connection per worker; use PgBouncer or ProxySQL at scale
Q42How would you implement multi-tenancy in Laravel, and what leaks tenant data in practice?
AdvancedArchitecture
Answer
There are three models and the interview is really about the trade-offs. Single database with a tenant_id column on every table is the cheapest to operate: one migration run, one connection pool, easy cross-tenant reporting, but every single query must be scoped or you leak. Database-per-tenant gives hard isolation and per-tenant restore, at the cost of running migrations N times and holding N connections, which becomes painful past a few hundred tenants.
Schema-per-tenant on Postgres sits in between. For the shared-database model, the mechanism is a global scope plus a bound tenant context: a middleware resolves the tenant from the subdomain, host or JWT claim, stores it in a scoped binding, and a BelongsToTenant trait adds both a global scope on the query and a creating hook that stamps tenant_id automatically. What actually leaks in production, in rough order of frequency: queued jobs, because a job runs in a fresh process where no middleware ran, so the tenant context is empty and the global scope resolves to null or, worse, to the previous job's tenant; artisan commands and the scheduler, for the same reason; raw DB::table() queries and any relation loaded with withoutGlobalScopes(); cached keys without a tenant prefix, so tenant A's dashboard is served to tenant B; and file paths and full-text indexes built without a tenant segment.
The defences are to serialise the tenant id into every job and re-establish the context in a job middleware, to prefix every cache key and queue name, to test with a tenant-crossing assertion in the suite, and to add tenant_id to unique indexes so the database itself refuses a cross-tenant collision. Under Octane, add the tenant context to the flush list or it survives into the next request.
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $q) {
if ($id = app(TenantContext::class)->id()) {
$q->where($q->getModel()->getTable() . '.tenant_id', $id);
}
});
static::creating(function (Model $model) {
$model->tenant_id ??= app(TenantContext::class)->id();
});
}
}
// Jobs run without middleware, so carry the tenant explicitly
class RebuildTenantIndex implements ShouldQueue
{
public function __construct(public int $tenantId) {}
public function middleware(): array
{
return [new BindsTenant($this->tenantId)];
}
}
// Prefix caches or you will serve tenant A's data to tenant B
Cache::remember('t:' . $tenantId . ':dashboard', 300, $callback);
// Let the database enforce it too
$table->unique(['tenant_id', 'email']);
Key Points
- Shared column, database-per-tenant and schema-per-tenant have different operational costs
- Global scope plus a creating hook is the shared-database mechanism
- Queued jobs, artisan commands and the scheduler run with no tenant context
- Prefix cache keys, storage paths and search indexes with the tenant
- Put tenant_id in unique indexes so the database refuses cross-tenant collisions
Q43You are handed an unfamiliar Laravel codebase for a security review. What do you check, in order?
AdvancedSecurity
Answer
Start with configuration, because the highest-severity findings are usually one line. APP_DEBUG=true in production leaks stack traces, environment variables and database credentials through the Ignition error page, and APP_ENV left as local disables several safety rails. Confirm APP_KEY is set, unique per environment and not the one committed in .env.example, because it encrypts sessions, cookies and every encrypted cast; rotating it invalidates existing ciphertext, so plan a re-encryption if it was ever exposed.
Check that .env is not readable over HTTP and that the web root points at public/, not the project root, which is the single most damaging misconfiguration on shared hosting still common in Indian SMB deployments. Then the code. Grep for $guarded = [] and for ::create($request->all()) to find mass assignment.
Grep for whereRaw, orderByRaw and DB::raw with string interpolation to find injection. Look for {!! !!} in Blade rendering user input, which is stored XSS unless it passes a sanitiser. Check that every controller action with an id parameter enforces authorisation, not just authentication, because policy-free actions on route-bound models are the standard IDOR finding, and confirm nested routes use scopeBindings().
Verify file uploads validate mimetypes rather than the client extension and store outside the web root. Confirm webhook endpoints verify a provider signature and are exempted from CSRF deliberately rather than by accident. Check that dependency versions are supported (composer audit lists known CVEs) and that debug tooling such as Telescope is gated behind a gate in production. Finally, look at what is logged: full request bodies logged at info level put card data and OTPs into log aggregation, which is a compliance problem under India's DPDP framework, so confirm the sensitive keys are in the never-log list.
# Configuration findings first
php artisan about --only=environment
grep -rn 'APP_DEBUG=true' .env
composer audit
# Code smells that map to real vulnerability classes
grep -rn 'guarded = \[\]' app/Models
grep -rn 'request()->all()\|\$request->all()' app/Http/Controllers
grep -rn 'whereRaw\|orderByRaw\|DB::raw' app/ | grep -v '?'
grep -rn '{!!' resources/views
# Gate debug tooling in production
// AppServiceProvider::boot()
Gate::define('viewTelescope', fn ($user) => $user->hasRole('sre'));
// Keep secrets out of logs
// config/logging.php or a custom formatter
$never = ['password', 'password_confirmation', 'card', 'cvv', 'otp', 'token'];
Key Points
- APP_DEBUG, APP_ENV, APP_KEY and the document root are the first four checks
- $guarded = [] and $request->all() into create() are mass assignment findings
- Raw query helpers with interpolation are the injection surface
- Authorisation on bound models, not just authentication, prevents IDOR
- Gate Telescope and Horizon in production and scrub secrets from logs
Q44A production endpoint that used to take 200 ms now takes 4 seconds. How do you find the cause in a Laravel app?
AdvancedPerformance
Answer
Work from evidence, not intuition. First establish where the time goes: is it PHP, the database, an external HTTP call, or queue backpressure. Laravel Pulse is the first-party answer and is safe to run in production, giving you slow queries, slow requests, slow outgoing HTTP calls, slow jobs, exceptions and per-user activity on one dashboard with sampling so it does not become the bottleneck itself.
Telescope is far more detailed but is a development and staging tool, its per-request entries hammer the database under real traffic, so if you enable it in production, gate it, prune it aggressively with telescope:prune, and restrict recording to a sampled slice. For a single request you can also log the query count with DB::listen() behind a feature flag, and the fastest reproduction of the classic cause is to enable Model::preventLazyLoading() in staging, because a relation lazily loaded inside a Blade loop is the most common way a 200 ms endpoint becomes 4 seconds after someone added a column to a partial. Next, look at the SQL itself: run EXPLAIN on the slow query, check for a missing index on the new WHERE or ORDER BY column, and check whether a table simply grew past the point where a full scan was acceptable.
Other frequent causes in real incidents: an external API call added to the request path with no timeout, so p99 tracks the vendor's worst day; a cache that used to be warm now missing because the key includes a timestamp; serialising a huge collection to JSON; and OPcache disabled or opcache.max_accelerated_files too low after the codebase grew, so PHP recompiles files every request. Always set explicit timeouts on Http calls and a global request timeout so a slow dependency degrades rather than exhausts your FPM pool.
// Turn silent N+1 into a loud exception outside production
// AppServiceProvider::boot()
Model::shouldBeStrict(! app()->isProduction());
// Or target it precisely
Model::preventLazyLoading(! app()->isProduction());
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
Log::warning('lazy load', ['model' => $model::class, 'relation' => $relation]);
});
// Log slow queries in production without Telescope overhead
DB::listen(function ($query) {
if ($query->time > 500) {
Log::warning('slow query', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'ms' => $query->time,
]);
}
});
// Never let a vendor own your p99
$response = Http::timeout(3)->connectTimeout(1)->retry(2, 200)
->get('https://api.vendor.com/status');
Q45Laravel gives you controllers, models and services. How do you keep a 300,000-line Laravel codebase maintainable?
AdvancedArchitecture
Answer
This is a judgement question and interviewers are listening for whether you can defend a position rather than recite patterns. The failure mode of a large Laravel app is not the framework, it is a 4,000-line model and a controller that does validation, business logic, persistence and formatting in one method. The patterns that hold up: FormRequests own validation, API Resources own output shape, single-purpose action or service classes own one business operation each with a single public method, and the model stays a persistence concern with relationships, casts and scopes only.
Query complexity moves into query builder classes or scopes rather than living inline in controllers. Anything that can fail slowly or independently becomes a job. Once the app is genuinely large, group code by domain (app/Domains/Billing, app/Domains/Recruitment) rather than by technical layer, keep each domain's routes, jobs and models together, and enforce the boundaries with a static analysis rule or Pest's arch testing so a Billing class cannot reach into Recruitment internals.
On the repository pattern, have an opinion: wrapping Eloquent in a repository purely to abstract the database is usually cargo cult, because you will never swap Eloquent for something else and the repository ends up leaking Builder objects anyway; it earns its place only when you genuinely need to swap an implementation, for example a search backed by MySQL in development and OpenSearch in production. Add PHPStan or Larastan at a level the team can actually hold, Pint for formatting so nobody argues about style in review, and a test suite fast enough that people run it. The honest answer includes what you would not do: no premature microservices, no hexagonal architecture on a CRUD app, no interface for every class.
// One business operation, one class, one public method
final class CapturePayment
{
public function __construct(
private PaymentGateway $gateway,
private LedgerWriter $ledger,
) {}
public function handle(Order $order, string $token): Payment
{
return DB::transaction(function () use ($order, $token) {
$charge = $this->gateway->charge($order->total, $token);
$payment = $order->payments()->create([
'gateway_id' => $charge->id,
'amount' => $order->total,
'status' => 'captured',
]);
$this->ledger->record($payment);
SendReceipt::dispatch($payment)->afterCommit();
return $payment;
}, 3);
}
}
// The controller stays a thin HTTP shell
public function store(CapturePaymentRequest $request, Order $order, CapturePayment $action)
{
$this->authorize('pay', $order);
return new PaymentResource($action->handle($order, $request->validated('token')));
}
Key Points
- FormRequest for input, Resource for output, action class for the operation
- Models hold relationships, casts and scopes, not business workflows
- Group by domain once the app is large, and enforce boundaries with arch tests
- Repositories over Eloquent are usually unnecessary indirection
- Larastan plus Pint plus a fast test suite beats any architecture diagram
Frequently Asked Questions
What does a Laravel developer earn in India in 2026?
Roughly ₹5-18 LPA depending on years and employer type. Freshers and 0-2 year developers at agencies and service firms typically see ₹3-6 LPA, 3-5 year developers land ₹8-14 LPA, and senior or lead Laravel engineers at product companies reach ₹16-25 LPA. Location moves the number: Bengaluru, Pune, Hyderabad and Gurugram pay noticeably more than tier-2 cities for the same experience. The biggest single lever is not years but scope, developers who can show queue architecture, payment integration, database tuning and production debugging negotiate a different band from those whose experience is CRUD screens and Blade templates.
How long should I prepare for a Laravel interview?
If you already write Laravel daily, two to three weeks of focused revision is enough: one week on framework internals (container, providers, facades, Eloquent relationships, middleware), one week on queues, caching, transactions and testing, and a few days building or reviewing one small project you can discuss in depth. If you are coming from plain PHP or CodeIgniter, budget six to eight weeks and build something real, an application with authentication, a queue, a scheduled job and a payment integration teaches more than any question list. Candidates who can narrate one production bug they debugged themselves consistently interview better than candidates who have memorised definitions.
What is asked differently for freshers versus experienced Laravel developers?
Freshers are tested on mechanics: MVC flow, routing, Blade syntax, Eloquent relationships, migrations, validation rules, and whether you can write a small CRUD feature live. Expect definition-style questions and a coding round. From roughly three years, the conversation shifts to consequences: why a query is slow, why a job ran twice, why config caching broke an environment variable, how you would handle two concurrent requests hitting the same row. Senior rounds are almost entirely architecture and incident stories, multi-tenancy, deploy strategy, queue topology, and the trade-offs you chose. If you have three years of experience and the interviewer is still asking what a facade is, they are calibrating, so answer the mechanics quickly and then volunteer the production angle.
Is Laravel still worth learning in 2026 with Node.js and Go around?
Yes, for a specific reason: the volume of Laravel work in India is large and steady. Agencies, ecommerce platforms, ERP and CRM products, edtech backends, government and enterprise portals, and a very large base of existing PHP applications all need Laravel maintenance and new feature work. Node.js and Go dominate different niches, real-time systems and high-concurrency infrastructure, and neither has displaced Laravel for conventional business web applications where developer velocity matters most. The realistic career view is that Laravel is a reliable primary skill with strong demand and a slightly lower ceiling than Go or distributed-systems work, so many developers pair it with one additional skill such as Vue, React or AWS to widen the range of roles they qualify for.
Laravel or Node.js and NestJS for a backend career in India?
Pick Laravel if you want the shortest path to employability and you are targeting agencies, ecommerce, SaaS products and enterprise portals, where the volume of openings is highest and the framework is unusually complete out of the box. Pick Node.js with NestJS if you want to work on real-time systems, if the team runs TypeScript end to end, or if you are aiming at fintech and startup product teams where that stack is more common and salary bands run higher (roughly ₹8-25 LPA for NestJS versus ₹5-18 LPA for Laravel). The skills transfer more than people expect: dependency injection, queues, migrations, ORMs, middleware and testing are the same ideas in both, so moving across after two years of Laravel is a matter of weeks, not a restart.
Which Laravel version should I study, and how much plain PHP do I need?
Study the current release line and know what changed recently, because interviewers use version questions to check whether you have kept up. The important shift to be able to discuss is the Laravel 11 skeleton, which removed app/Http/Kernel.php and the middleware and exception arrays in favour of bootstrap/app.php, moved providers to bootstrap/providers.php, and slimmed the default config files. Be honest if your current project is on Laravel 9 or 10, plenty of Indian codebases are, but be able to explain the migration. On PHP itself, you need more than syntax: typed properties, constructor property promotion, enums, readonly, named arguments, match, attributes, traits, interfaces and how Composer autoloading resolves a class. Most Laravel rejections at the mid level are actually PHP fundamentals failures, not framework failures.
Introduction
Laravel remains the framework that most PHP work in India actually runs on. Since the Laravel 11 skeleton stripped out Kernel.php and moved middleware, exception handling and routing configuration into bootstrap/app.php, and Laravel 12 kept breaking changes deliberately small while shipping first-party starter kits, the framework has settled into a stable, boring-in-a-good-way shape. Around it sits a first-party toolchain that interviewers now assume you know: Octane for persistent workers, Horizon for queue visibility, Pulse and Telescope for runtime insight, Pest and PHPUnit for tests, Pint for formatting, and Sail or Herd for local environments.
Laravel interviews in India split cleanly into two conversations. The first is framework mechanics: the service container, service providers, facades versus injection, Eloquent relationships, route model binding, FormRequest validation, queues and the scheduler. The second, and the one that decides mid and senior offers, is production behaviour: why chunk() silently skips rows, why config caching turns every env() call outside config/ into null, why a queue worker keeps running last week's code after a deploy, how a payment endpoint deadlocks under concurrent requests, and which perfectly normal Laravel patterns leak state the moment you put the app behind Octane.
This page covers 45 Laravel interview questions asked in 2026, ordered from fundamentals through to architecture, with a working code example on most of them. Work through the basic section if you are targeting 0-3 year roles at service firms like TCS, Infosys, Wipro or Cognizant, where Laravel powers a large amount of client delivery work. The intermediate and advanced sections cover the ground that separates a ₹6 LPA developer from a ₹18 LPA one: queue semantics, transaction safety, Octane, multi-tenancy, deployment and security review.
Ready to practice Laravel interviews?
Don't just read, practice these Laravel questions live with an AI interviewer that asks follow-ups and scores your answers.