DEV Community

Cover image for How To Prepare For A Laravel Interview
Nazar Boyko
Nazar Boyko

Posted on • Originally published at nazarboyko.com

How To Prepare For A Laravel Interview

Have you noticed how every Laravel job posting wants someone who can "design scalable backends, optimize queries, and architect maintainable systems", and then the interview opens with "what is a service container?"

That's the dance. Senior interviews mix the basics with system design questions, and you need to be ready for both.

This guide is the prep plan I use myself. It covers core Laravel internals, the senior topics that interviewers actually probe, and the architectural questions that separate "I've used Laravel" from "I've shipped Laravel at scale."

It's structured so you can spread it across anywhere from one to ten days, depending on how rusty you feel. Skim what you know, drill what you don't.

Image prompts. Every diagram below has a copy-ready GPT image prompt right beside it. The cover prompt sits at the very end of the article.

How To Use This Guide

Treat each section as a checkpoint. If you can explain the concept out loud, write a small example, and answer the "why" behind it, you're ready to move on.

A practical rhythm:

  1. 1-2 days available. Focus on Request Lifecycle, Eloquent + N+1, Queues, Validation, Authorization. Skim everything else.
  2. 3-5 days available. Add Caching, Database Transactions, Testing, and API Design. Run through the senior scenarios at the end at least once.
  3. 6-10 days available. Cover everything. Build a tiny side project that exercises queues, events, jobs, and policies, the act of writing it is worth more than reading any guide.

The goal isn't memorization. It's being able to talk fluently about tradeoffs.

Request Lifecycle

A Laravel request enters through public/index.php. From there, it's a fairly choreographed sequence, knowing it well makes you sound senior fast.

public/index.php

require __DIR__.'/../vendor/autoload.php';

$app = require_once __DIR__.'/../bootstrap/app.php';

$app->handleRequest(Request::capture());
Enter fullscreen mode Exit fullscreen mode

The path your request takes:

  1. Autoloader and bootstrap. Composer's autoloader runs, and bootstrap/app.php builds the application container.
  2. Service providers register. Every provider's register() method runs first. It only adds bindings, no resolving dependencies yet.
  3. Service providers boot. Then boot() runs on each provider. By this point all bindings exist, so you can resolve other services safely.
  4. HTTP kernel handles the request. Global middleware runs (encrypt cookies, trim strings, CSRF for web routes).
  5. Route resolution. The router matches the URL, then runs route-level middleware (auth, throttle, verified, signed, can:*).
  6. Controller or route action runs. Dependencies inject through the service container.
  7. Response returns. It bubbles back through "after" middleware and out to the client.

In Laravel 11+, bootstrap/app.php is your central wiring file, middleware, exception handling, and routes are configured there instead of in separate Kernel classes.

Detailed seven-stage flow diagram of a Laravel HTTP request lifecycle, from public/index.php to the response, with arrows showing register/boot phases, kernel middleware, route resolution, and controller execution.

Likely interview question

"What's the difference between register() and boot() in a service provider?"

Strong answer: register() only binds things into the container. You should not resolve other services from register() because the container might not have everything wired yet. boot() runs after every provider has registered, which means it's safe to resolve dependencies, register event listeners, publish config, or define route patterns there.

Routing And Middleware

Routes are declarative. The patterns to know cold:

routes/api.php

Route::middleware(['auth:sanctum', 'throttle:60,1'])
    ->prefix('v1')
    ->group(function () {
        Route::apiResource('orders', OrderController::class);
        Route::post('orders/{order}/refund', [RefundController::class, 'store']);
    });
Enter fullscreen mode Exit fullscreen mode

What middleware actually is

Middleware sits between the request and your application logic. It can inspect the request, modify it, short-circuit it (return a response early), let it pass through, or modify the response on the way out.

Common Laravel middleware:

  1. auth and auth:sanctum, authentication.
  2. throttle:60,1, rate limiting (60 requests per minute).
  3. verified, email verification gate.
  4. signed, verify signed URLs.
  5. can:update,post, authorization via policies.

A trap interviewers sometimes set: "Where would you put rate limiting, middleware, controller, or service?" Middleware is the answer. It's cross-cutting infrastructure, not business logic. Same goes for CSRF, locale, and auth.

Route Model Binding

A Laravel feature interviewers love asking about because it shows whether you've moved past basic routing.

routes/api.php

// Implicit binding — Laravel resolves Order from {order} via the model's primary key
Route::get('orders/{order}', function (Order $order) {
    return $order;
});
Enter fullscreen mode Exit fullscreen mode

Custom key:

// Resolve by slug instead of id
Route::get('posts/{post:slug}', function (Post $post) {
    return $post;
});
Enter fullscreen mode Exit fullscreen mode

Scoped binding, when one model belongs to another and you want the URL to reflect that:

// Will only resolve a comment that belongs to the given post
Route::get('posts/{post}/comments/{comment}', function (Post $post, Comment $comment) {
    //
})->scopeBindings();
Enter fullscreen mode Exit fullscreen mode

You can also override resolution per model:

app/Models/Post.php

public function resolveRouteBinding($value, $field = null): ?Model
{
    return $this->where($field ?? 'slug', $value)
        ->whereNotNull('published_at')
        ->first();
}
Enter fullscreen mode Exit fullscreen mode

The senior signal here: "Route model binding plus scoped bindings means you push validation of relationships into the routing layer, which keeps controllers thin and prevents whole categories of authorization bugs."

Service Container And Dependency Injection

The container is Laravel's brain. Get this right and you'll sound senior immediately.

app/Providers/AppServiceProvider.php

public function register(): void
{
    // New instance every resolve
    $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);

    // One shared instance for the whole app lifecycle
    $this->app->singleton(MetricsClient::class, fn () => new MetricsClient(
        host: config('metrics.host'),
    ));

    // One instance per request — important for Octane
    $this->app->scoped(TenantContext::class, TenantContext::class);
}
Enter fullscreen mode Exit fullscreen mode

The interview-tested distinction:

  1. bind(), new instance each resolve. Use for stateful or cheap services.
  2. singleton(), one instance for the whole process. Use for expensive or shared services like an HTTP client or a config reader.
  3. scoped(), one instance per request. Useful in long-running setups like Octane, where singleton can leak state between requests.

Diagram of the Laravel service container with three binding zones, bind (new instance), singleton (shared), scoped (one per request), surrounded by classes injecting dependencies, plus a contextual binding example using when()->needs()->give().

Contextual binding

When two classes need different implementations of the same interface:

$this->app->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('s3'));

$this->app->when(LogProcessor::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('local'));
Enter fullscreen mode Exit fullscreen mode

Drop this in an interview and you've signaled you've actually read the docs.

Constructor injection in the wild

app/Http/Controllers/ReportController.php

final class ReportController
{
    public function __construct(
        private readonly ReportService $reports,
        private readonly Logger $logger,
    ) {}

    public function index(): JsonResponse
    {
        return response()->json(
            $this->reports->generateMonthly()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Method injection works too, Laravel resolves dependencies on controller method signatures via the container.

Eloquent ORM

This is where most Laravel interviews spend the most time. Be ready for relationships, performance traps, and the difference between Eloquent and the Query Builder.

Models and relationships

app/Models/Order.php

final class Order extends Model
{
    protected $fillable = ['user_id', 'status', 'total'];

    protected function casts(): array
    {
        return [
            'total' => 'decimal:2',
            'placed_at' => 'immutable_datetime',
            'metadata' => 'array',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}
Enter fullscreen mode Exit fullscreen mode

Relationship types worth knowing cold:

  1. belongsTo, hasOne, hasMany, belongsToMany
  2. hasOneThrough, hasManyThrough
  3. morphTo, morphOne, morphMany, morphToMany, morphedByMany (polymorphic)

In Laravel 11+, casts are defined as a method, not a property, interviewers may ask why this matters (it makes casts dependency-injectable and lazily evaluated).

Query scopes

Local scopes keep query logic on the model:

app/Models/Order.php

public function scopeRecent(Builder $query, int $days = 30): void
{
    $query->where('placed_at', '>=', now()->subDays($days));
}

// Usage
$recent = Order::recent(7)->get();
Enter fullscreen mode Exit fullscreen mode

Global scopes apply to every query for a model, handy for soft deletes, multi-tenancy, or "active" filters. Be careful: they can hide queries from teammates who don't know about them. Document them.

Observers

Observers move model lifecycle hooks out of the model itself:

app/Observers/OrderObserver.php

final class OrderObserver
{
    public function creating(Order $order): void
    {
        $order->reference ??= Str::uuid();
    }

    public function updated(Order $order): void
    {
        if ($order->wasChanged('status')) {
            OrderStatusChanged::dispatch($order);
        }
    }

    public function deleted(Order $order): void
    {
        $order->items()->delete();
    }
}
Enter fullscreen mode Exit fullscreen mode

Hooks available: creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, forceDeleted, retrieved.

When interviewers ask "where would you put logic that runs whenever an order is updated?", observers are usually the right answer. They keep models clean and make lifecycle behavior testable.

The senior caveat: observers don't fire when you do mass updates like Order::query()->update([...]). Bring this up, it's a classic "gotcha" interviewers like to probe.

Soft deletes

app/Models/Post.php

use SoftDeletes;
Enter fullscreen mode Exit fullscreen mode

Soft deletes mark a row as deleted with a deleted_at timestamp instead of removing it. Useful for audit trails, undo functionality, and recovering accidentally-deleted records.

$post->delete();           // soft delete (sets deleted_at)
$post->restore();          // un-delete
$post->forceDelete();      // actually remove from the database

Post::withTrashed()->get();   // include soft-deleted
Post::onlyTrashed()->get();   // only soft-deleted
Enter fullscreen mode Exit fullscreen mode

Don't soft-delete reflexively, every query becomes WHERE deleted_at IS NULL, which has indexing and performance implications. Use it where the business actually needs recoverability.

The N+1 problem

Probably the most common Laravel interview question.

// Bad: 1 query for orders + N queries for users
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->user->name;
}

// Good: 2 queries total
$orders = Order::with('user')->get();
Enter fullscreen mode Exit fullscreen mode

Side-by-side comparison of the N+1 query problem: 101 queries without eager loading vs 2 queries with Order::with('user'), plus a toolkit panel listing with(), load(), loadMissing(), withCount(), withExists(), and Model::preventLazyLoading().

Tools to namedrop:

  1. with(), eager load on the initial query.
  2. load(), eager load after the fact.
  3. loadMissing(), eager load only if not already loaded.
  4. withCount(), eager load relationship counts.
  5. withExists(), load a boolean for "has any related rows."
  6. Model::preventLazyLoading(), in non-production, throws when lazy loading happens. Forces you to fix N+1 problems early.

Eloquent vs Query Builder

// Eloquent — model objects, relationships, events, casts
$users = User::where('active', true)->get();

// Query Builder — raw rows, faster, no model overhead
$users = DB::table('users')->where('active', true)->get();
Enter fullscreen mode Exit fullscreen mode

Use Eloquent for business logic. Drop to the Query Builder for reports, analytics, or bulk operations where model overhead actually matters.

Chunking large datasets

When you process millions of rows, don't ->get() everything:

// Loads all rows into memory — bad for large tables
Order::all()->each(fn ($o) => $this->process($o));

// Better: chunk in batches, but careful when modifying records
Order::orderBy('id')->chunk(1000, fn ($orders) => $orders->each(...));

// Best for stable iteration when records are being modified
Order::lazyById(1000)->each(fn ($o) => $this->process($o));
Enter fullscreen mode Exit fullscreen mode

chunkById() and lazyById() are safer than chunk() if you're updating records as you iterate, because they paginate by primary key instead of offset. With offset paging plus updates, you skip rows.

Validation And Form Requests

Inline validation is fine for small endpoints:

$data = $request->validate([
    'email' => ['required', 'email'],
    'age' => ['required', 'integer', 'min:18'],
]);
Enter fullscreen mode Exit fullscreen mode

For anything serious, use Form Requests:

app/Http/Requests/StoreOrderRequest.php

final class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Order::class);
    }

    public function rules(): array
    {
        return [
            'product_id' => ['required', 'exists:products,id'],
            'quantity' => ['required', 'integer', 'min:1', 'max:100'],
            'shipping_address' => ['required', 'array'],
            'shipping_address.street' => ['required', 'string', 'max:255'],
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'quantity' => (int) $this->quantity,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Form Requests centralize validation, authorization, and input transformation. Always prefer $request->validated() over $request->all() to avoid mass assignment leaks.

Authentication And Authorization

Sanctum vs Passport

A common interview prompt: "Which would you choose for an API?"

  1. Sanctum, for first-party SPAs, mobile apps, and simple token-based APIs. Lightweight. Uses session cookies for SPAs and personal access tokens for mobile/API clients.
  2. Passport, full OAuth2 server. Use it when you actually need OAuth2 flows: third-party clients, authorization codes, refresh tokens, scopes at the protocol level.

If you don't need OAuth2 explicitly, Sanctum is the modern default. Naming this tradeoff is the senior signal.

Policies and Gates

Policies handle authorization for specific models:

app/Policies/OrderPolicy.php

final class OrderPolicy
{
    public function update(User $user, Order $order): bool
    {
        return $user->id === $order->user_id
            || $user->hasRole('admin');
    }
}
Enter fullscreen mode Exit fullscreen mode
// In a controller
$this->authorize('update', $order);

// In Blade
@can('update', $order) ... @endcan

// In a Form Request
public function authorize(): bool
{
    return $this->user()->can('update', $this->route('order'));
}
Enter fullscreen mode Exit fullscreen mode

Gates handle one-off authorization that doesn't tie to a model:

Gate::define('access-admin-panel', fn (User $user) => $user->is_admin);
Enter fullscreen mode Exit fullscreen mode

Queues And Jobs

Queues are non-negotiable for production Laravel. Anything that's slow or unreliable should not run inside an HTTP request.

Anatomy of a job

app/Jobs/ProcessPayment.php

final class ProcessPayment implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public int $backoff = 30;
    public int $timeout = 120;

    public function __construct(public int $orderId) {}

    public function handle(PaymentGateway $gateway): void
    {
        $order = Order::findOrFail($this->orderId);
        $gateway->charge($order);
    }

    public function failed(Throwable $e): void
    {
        Log::error('Payment failed permanently', [
            'order_id' => $this->orderId,
            'error' => $e->getMessage(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice we pass $orderId, not $order. Jobs are serialized to the queue. Passing IDs and re-fetching inside handle() keeps payloads small and avoids stale data.

End-to-end queue lifecycle: a controller dispatches ProcessPayment, the worker pulls from Redis and calls handle(), with three outcomes (success / retry with backoff / failed → failed_jobs table), plus a Horizon dashboard banner showing throughput and queue depth.

Queue drivers

  1. Redis, most common production choice. Fast, supports delayed jobs, good Horizon integration.
  2. Database, fine for low volume; lock contention bites at scale.
  3. SQS / Beanstalkd, managed alternatives.
  4. Sync, runs immediately. Useful for testing.

Failed jobs, retries, backoff

php artisan queue:failed
php artisan queue:retry 5
php artisan queue:retry all
php artisan queue:forget 5
Enter fullscreen mode Exit fullscreen mode

Configure retry behavior on the job ($tries, $backoff, $timeout) or in the worker. For idempotent jobs, retries are safe; for non-idempotent jobs (charging cards, sending emails), design carefully, see the idempotency section below.

Batches and chains

// Run sequentially, stop on failure
Bus::chain([
    new ChargeCustomer($order),
    new SendReceipt($order),
    new UpdateInventory($order),
])->dispatch();

// Run in parallel, track collective progress
Bus::batch([
    new ProcessImage($asset1),
    new ProcessImage($asset2),
    new ProcessImage($asset3),
])->then(fn (Batch $batch) => /* all succeeded */)
  ->catch(fn (Batch $batch, Throwable $e) => /* one failed */)
  ->dispatch();
Enter fullscreen mode Exit fullscreen mode

Horizon

Horizon is the dashboard and supervisor for Redis-backed queues. It gives you metrics, failed-job inspection, throughput graphs, and worker auto-balancing. If your interviewer asks about queue monitoring, this is the answer.

Events And Listeners

Events let you decouple. The order pays, you don't want the controller to know about emails, inventory, analytics, and webhooks.

app/Events/OrderPaid.php

final class OrderPaid
{
    public function __construct(public Order $order) {}
}
Enter fullscreen mode Exit fullscreen mode

app/Listeners/SendReceipt.php

final class SendReceipt implements ShouldQueue
{
    public function handle(OrderPaid $event): void
    {
        Mail::to($event->order->user)
            ->send(new ReceiptMail($event->order));
    }
}
Enter fullscreen mode Exit fullscreen mode
// Fire it
OrderPaid::dispatch($order);
Enter fullscreen mode Exit fullscreen mode

In modern Laravel, listeners are auto-discovered if you put them in app/Listeners and type-hint the event in handle().

Pub/sub diagram of Laravel events: a Controller dispatches OrderPaid, which fans out to three listeners (SendReceipt and UpdateInventory queued, NotifyAccounting sync), plus a broadcasting panel showing the same event published over WebSockets via Reverb.

Listeners that implement ShouldQueue run on the queue. Otherwise they run synchronously inside the request and slow it down. Email and webhook calls should always be queued.

Broadcasting

Broadcasting publishes events to clients in real time (Pusher, Reverb, Soketi). If you've used WebSockets in Laravel, mention this, it shows you've worked beyond CRUD.

Notifications

Laravel's notification system unifies multi-channel messaging:

app/Notifications/OrderShipped.php

final class OrderShipped extends Notification implements ShouldQueue
{
    use Queueable;

    public function via(User $user): array
    {
        return ['mail', 'database', 'broadcast'];
    }

    public function toMail(User $user): MailMessage { /* ... */ }
    public function toDatabase(User $user): array { /* ... */ }
    public function toBroadcast(User $user): BroadcastMessage { /* ... */ }
}

// Usage
$user->notify(new OrderShipped($order));
Enter fullscreen mode Exit fullscreen mode

Channels include mail, database, broadcast, Vonage (SMS), Slack, and any custom driver you implement. Database notifications power in-app inboxes; broadcast notifications power real-time UI updates. If asked "how would you build an in-app notification center?", this is the answer.

Caching And Redis

$products = Cache::remember('homepage:featured', 3600, fn () =>
    Product::where('featured', true)->take(10)->get()
);
Enter fullscreen mode Exit fullscreen mode

Strategies

  1. Read-through, Cache::remember() style. Miss the cache, fetch, store.
  2. Write-through, update cache when you update the database.
  3. Cache-aside, application explicitly invalidates cache keys on writes.
  4. Tagged cache, group related cache entries for batch invalidation (Redis only).
Cache::tags(['products', "user:{$user->id}"])
    ->remember('user-feed', 600, fn () => buildFeed($user));

// Later, blow away everything tagged 'products'
Cache::tags(['products'])->flush();
Enter fullscreen mode Exit fullscreen mode

A 2x2 grid of Laravel caching strategies, read-through, write-through, cache-aside, and tagged cache, plus a callout explaining cache stampede and the two fixes (atomic locks via Cache::lock() and stale-while-revalidate via Cache::flexible()).

Atomic locks

For preventing duplicate operations:

$lock = Cache::lock("payment:{$order->id}", 10);

if ($lock->get()) {
    try {
        $this->charge($order);
    } finally {
        $lock->release();
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the right answer when interviewers ask "how would you prevent double-charging?"

Cache stampede

When a popular cache key expires and 1000 requests hit the database simultaneously. Mitigation: atomic locks around cache regeneration, or Cache::flexible() (Laravel 11+), which serves stale data while a single worker refreshes.

Database

Migrations

database/migrations/2026_01_01_000000_create_orders_table.php

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('status')->index();
            $table->decimal('total', 10, 2);
            $table->timestamps();

            $table->index(['user_id', 'status']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};
Enter fullscreen mode Exit fullscreen mode

Migration tips for senior interviews:

  1. Always make migrations reversible.
  2. Add indexes deliberately, not reflexively.
  3. Never edit a deployed migration, write a new one.
  4. For zero-downtime, decompose risky migrations: add column → backfill → switch reads → drop old column. Don't combine those steps in one release.

Transactions

DB::transaction(function () use ($order) {
    $order->update(['status' => 'paid']);
    Inventory::decrement($order);
    Receipt::create(['order_id' => $order->id]);
});
Enter fullscreen mode Exit fullscreen mode

DB::transaction() retries on deadlocks (configurable). For nested transactions, Laravel uses savepoints under the hood.

Pessimistic vs optimistic locking

Pessimistic, lock the row in the database while you work on it:

DB::transaction(function () {
    $product = Product::lockForUpdate()->find(1);
    $product->stock -= 1;
    $product->save();
});
Enter fullscreen mode Exit fullscreen mode

Optimistic, use a version column and check on update:

$updated = Product::where('id', $id)
    ->where('version', $currentVersion)
    ->update([
        'stock' => $newStock,
        'version' => $currentVersion + 1,
    ]);

if ($updated === 0) {
    throw new StaleDataException();
}
Enter fullscreen mode Exit fullscreen mode

Side-by-side diagram comparing pessimistic locking (DB-level row lock via lockForUpdate inside a transaction, with another worker waiting) versus optimistic locking (version column check on UPDATE, retry on stale data), plus a tradeoff panel.

Pessimistic is simpler but holds locks. Optimistic is more scalable but requires retry logic. Naming both and the tradeoff is what senior interviewers want to hear.

Indexing

Indexes you should know:

  1. B-tree (default), good for equality and range.
  2. Composite indexes, column order matters; left-most prefix rule.
  3. Unique indexes, also enforce data integrity.
  4. Partial / functional indexes (Postgres), index a subset or expression.
  5. Full-text indexes, for LIKE '%term%' substitutes.

The senior take: indexes speed up reads but slow down writes. Profile before adding.

Pagination, and why cursor pagination matters

// Offset-based — simple, but slow on large tables
$users = User::paginate(50);

// Cursor-based — uses a key, not OFFSET. Fast even on millions of rows.
$users = User::orderBy('id')->cursorPaginate(50);
Enter fullscreen mode Exit fullscreen mode

Why this is a senior question: paginate() runs SELECT COUNT(*) plus LIMIT/OFFSET, both of which get expensive past hundreds of thousands of rows. cursorPaginate() skips the count and uses a WHERE id > last_seen_id filter, constant time regardless of position.

Tradeoff: cursor pagination doesn't support "jump to page 47." Use it for infinite-scroll feeds, API endpoints, and large admin tables. Use offset pagination for small datasets where users want page numbers.

Testing

tests/Feature/OrderControllerTest.php

final class OrderControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_creates_order(): void
    {
        $user = User::factory()->create();
        $product = Product::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/orders', [
            'product_id' => $product->id,
            'quantity' => 2,
        ]);

        $response->assertCreated();
        $this->assertDatabaseHas('orders', [
            'user_id' => $user->id,
            'product_id' => $product->id,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing pyramid for a Laravel application: wide unit tests at the base, feature tests in the middle, narrow Dusk browser tests at the top, plus a fakes panel listing Bus, Queue, Mail, Event, Storage, and Notification fakes.

Test types

  1. Unit tests, pure classes, no framework, fastest.
  2. Feature tests, full HTTP cycle with the test client.
  3. Integration tests, database, queues, events working together.
  4. Browser tests (Dusk), actual JavaScript interaction.

Helpful traits and tools

  1. RefreshDatabase, wraps each test in a transaction; rollback after.
  2. DatabaseMigrations, re-runs migrations per test (slower, more isolated).
  3. WithFaker, faker instance for fixtures.
  4. Mockery, mocking dependencies.
  5. Bus::fake(), Queue::fake(), Mail::fake(), Event::fake(), Notification::fake(), assert dispatches without actually running them.

Parallel testing

php artisan test --parallel
Enter fullscreen mode Exit fullscreen mode

Speeds up large suites. Make sure your tests don't share state or rely on hardcoded IDs.

API Design

routes/api.php

Route::middleware(['auth:sanctum', 'throttle:api'])
    ->prefix('v1')
    ->group(function () {
        Route::apiResource('orders', OrderController::class);
    });
Enter fullscreen mode Exit fullscreen mode

Resources

app/Http/Resources/OrderResource.php

final class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'status' => $this->status,
            'total' => (float) $this->total,
            'items' => OrderItemResource::collection(
                $this->whenLoaded('items')
            ),
            'placed_at' => $this->placed_at?->toIso8601String(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

whenLoaded() is your N+1 prevention at the API layer, only includes the relation if it was eager-loaded.

Versioning

Two common approaches:

  1. URL versioning, /api/v1/.... Simple, visible.
  2. Header versioning, Accept: application/vnd.app.v1+json. Cleaner URLs, harder to debug.

URL versioning is what most teams use. Pick it unless you have a reason.

Rate limiting

app/Providers/AppServiceProvider.php

RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(20)->by($request->ip());
});
Enter fullscreen mode Exit fullscreen mode

Keys matter. Rate limit by user ID for authenticated requests, by IP otherwise.

Custom exception handling

A senior signal, your APIs should return predictable error shapes, not Laravel's debug pages.

bootstrap/app.php (Laravel 11+)

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ModelNotFoundException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => 'Resource not found',
            ], 404);
        }
    });

    $exceptions->render(function (ValidationException $e, Request $request) {
        return response()->json([
            'message' => 'Invalid input',
            'errors' => $e->errors(),
        ], 422);
    });
});
Enter fullscreen mode Exit fullscreen mode

Define a consistent error envelope for your API and stick to it. Interviewers respect "we standardized error responses across services so the front end never has to special-case" answers.

Performance And Optimization

Production checklist

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
composer install --optimize-autoloader --no-dev
Enter fullscreen mode Exit fullscreen mode

OpCache should be on. Preload if you can.

Octane

Octane runs your app on Swoole, RoadRunner, or FrankenPHP, keeping the framework booted between requests. Massive speedup, but you have to be careful:

  1. Singletons live across requests, state leaks are real.
  2. Static properties stick around between requests.
  3. Be deliberate with scoped bindings instead of singleton.
  4. Watch for memory leaks in long-running processes.

If your interviewer asks how you'd 5x throughput without changing infrastructure, "Octane plus careful state management" is the answer.

Database performance

  1. Eager loading (with, withCount).
  2. Targeted indexes.
  3. EXPLAIN your slow queries.
  4. Avoid SELECT *, use ->select(['id', 'name']).
  5. Read replicas for read-heavy workloads.
  6. Connection pooling (PgBouncer, ProxySQL) at scale.

Profiling tools

  1. Laravel Telescope, local dev request and query inspection.
  2. Laravel Pulse, production performance dashboard.
  3. Clockwork, alternative profiler.
  4. Blackfire / Tideways / New Relic, production-grade profiling.

Security

A senior shouldn't just say "Laravel is secure by default." Show that you know the failure modes.

SQL injection

Eloquent and the Query Builder use prepared statements. Raw queries with interpolated input are the danger:

// Never
DB::select("select * from users where email = '{$email}'");

// Always
DB::select('select * from users where email = ?', [$email]);
Enter fullscreen mode Exit fullscreen mode

Mass assignment

// Dangerous — accepts any field, including is_admin
User::create($request->all());

// Safe
User::create($request->validated());
Enter fullscreen mode Exit fullscreen mode

Configure $fillable (allow list) or $guarded (block list). Allow lists are safer because forgetting to guard a new column never leaks it.

XSS

Blade escapes output by default with {{ }}. Only {!! !!} skips escaping, use it deliberately and never on user input.

CSRF

The @csrf Blade directive in forms. Stateless API endpoints don't need CSRF if they use bearer tokens, but session-authenticated endpoints absolutely do.

Password hashing

bcrypt by default; argon2id available. Never store plain passwords. Never roll your own hashing.

Signed URLs

For public-but-tamper-proof links (password reset, file downloads, magic-link auth):

URL::temporarySignedRoute('download', now()->addMinutes(5), ['file' => $id]);
Enter fullscreen mode Exit fullscreen mode

Other senior-level mentions

  1. File upload validation (image, mimes, max: size).
  2. HTTPS enforcement and HSTS headers.
  3. Content Security Policy headers.
  4. Dependency scanning (composer audit).
  5. Secrets management, env files outside the repo, vault solutions in production.

Architecture Patterns

This is the senior section. Most candidates know the basics; few know how to actually structure a large Laravel app.

Layered architecture diagram for a Laravel application: Delivery (HTTP/controllers/Form Requests/resources), Application (services/actions/events/jobs), Domain (models/value objects), Infrastructure (Postgres/Redis/Stripe/S3), with a callout warning that the repository pattern is usually unnecessary in Laravel.

Service classes

A service holds business logic that doesn't belong on a model:

app/Services/OrderService.php

final class OrderService
{
    public function __construct(
        private readonly PaymentGateway $payments,
        private readonly InventoryService $inventory,
    ) {}

    public function place(User $user, array $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $order = $user->orders()->create($data);
            $this->inventory->reserve($order);
            $this->payments->authorize($order);
            return $order;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Action classes

A more granular alternative, one class, one operation:

app/Actions/PlaceOrder.php

final class PlaceOrder
{
    public function execute(User $user, array $data): Order
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Actions are great for clarity. Services group related actions when you want a coordinating layer.

Repository pattern, usually wrong in Laravel

The repository pattern is the most over-applied pattern in PHP. Laravel models are already repositories. Adding an OrderRepository that wraps Order::query() adds zero value and a layer of pain.

When does it make sense? Only when:

  1. You have a non-relational data source (e.g., a remote API).
  2. You genuinely need to swap implementations.
  3. You want to abstract storage from a domain model that should know nothing about Eloquent.

Otherwise, write a service or an action.

Domain-driven structure

For large applications, structure code by feature domain instead of by Laravel convention:

app/
├── Domain/
│   ├── Orders/
│   │   ├── Models/
│   │   ├── Actions/
│   │   ├── Events/
│   │   └── ValueObjects/
│   └── Billing/
└── Http/
Enter fullscreen mode Exit fullscreen mode

Mention this if asked about scaling a Laravel codebase past tens of thousands of lines.

Multi-tenancy

A common architectural question for SaaS roles. Three patterns to know:

  1. Single database, tenant_id column. Simplest. Every model carries a tenant_id. A global scope filters every query. Easy to operate, harder to keep tenants strictly isolated. Risk: forgetting the scope leaks data between tenants.
  2. Database-per-tenant. Each tenant gets their own database. Strong isolation. Harder to operate (migrations across N databases) and more expensive at scale. Packages like stancl/tenancy automate the connection switching.
  3. Schema-per-tenant (Postgres). One database, separate schemas per tenant. Middle ground between the two.

The senior conversation is about the tradeoffs. "We started with a tenant_id column and a global scope, and added paranoid checks at the controller layer to prevent cross-tenant access" is a good story.

When to bring in queues, events, services

A useful mental model:

  1. Controller, accept input, validate, hand off, return response.
  2. Service / action, execute a business operation.
  3. Event, broadcast that something happened.
  4. Listener, react to an event.
  5. Job, defer work that doesn't need to happen now.

A controller should rarely contain business logic. If it does, that logic is hard to reuse, hard to test, and impossible to call from a CLI command or a queued job.

Common Senior Scenarios

Idempotency

How do you make sure a payment endpoint isn't accidentally called twice?

$idempotencyKey = $request->header('Idempotency-Key');

return Cache::lock("idempotency:{$idempotencyKey}", 60)
    ->block(5, function () use ($idempotencyKey, $request) {
        $cached = Cache::get("response:{$idempotencyKey}");
        if ($cached) {
            return response()->json($cached);
        }

        $result = $this->orderService->place(...);
        Cache::put("response:{$idempotencyKey}", $result, 86400);

        return response()->json($result);
    });
Enter fullscreen mode Exit fullscreen mode

Race conditions

Two requests, same row. Use:

  1. lockForUpdate() for pessimistic locking inside a transaction.
  2. Cache::lock() for application-level coordination.
  3. Unique indexes for "impossible-by-design" conflicts.

Long-running jobs

Long jobs need:

  1. Bounded execution time ($timeout).
  2. Memory awareness (chunk, lazy collections).
  3. Safe retries ($tries, idempotency).
  4. Progress reporting (database row, Redis key, or batch).

Designing a system from scratch

If asked "design X with Laravel", think out loud about:

  1. Bounded contexts and tables.
  2. Public API surface (routes and resources).
  3. Synchronous path (controller → service → DB → response).
  4. Async path (events → listeners → jobs).
  5. Failure modes and observability.
  6. Scaling concerns: caching, queues, replicas.

DevOps And Deployment

Zero-downtime deploys

The classic flow:

  1. Pull new code to a release directory.
  2. composer install --no-dev.
  3. php artisan migrate --force (carefully).
  4. Cache config, routes, views.
  5. Atomically symlink current to the new release.
  6. Reload PHP-FPM (or restart Octane workers).

Tools: Envoyer, Forge, Deployer, GitHub Actions.

Migration safety in production

  1. Add a column nullable, backfill, then enforce.
  2. Avoid dropColumn in the same release that stops using it; do it next release.
  3. Index large tables concurrently (Postgres) or with low-impact tools (pt-online-schema-change for MySQL).

Task scheduling

Laravel's scheduler replaces a wall of crontab entries with code:

routes/console.php (Laravel 11+)

Schedule::command('reports:daily')->dailyAt('03:00');
Schedule::command('queue:prune-failed')->daily();
Schedule::job(new SyncInventoryJob)->hourly()->withoutOverlapping();
Enter fullscreen mode Exit fullscreen mode

A single cron entry runs php artisan schedule:run every minute, and Laravel decides what to fire. Useful flags: withoutOverlapping(), onOneServer(), runInBackground(), evenInMaintenanceMode(). Mention onOneServer() if you have multiple app servers, it ensures a job only runs on one of them.

Monitoring stack

Mention these:

  1. Horizon, queues.
  2. Telescope, local debugging.
  3. Pulse, production performance.
  4. Sentry / Bugsnag, exception tracking.
  5. Logs, Stackdriver, CloudWatch, Datadog.

Behavioral Questions That Actually Come Up

Senior interviews aren't only technical. Be ready for:

  1. "Tell me about a time you owned a system that failed in production."
  2. "How do you decide between fixing tech debt and shipping features?"
  3. "Describe a code review that changed your mind."
  4. "What's a Laravel decision your team made that you disagreed with?"
  5. "How do you onboard a junior developer onto a complex codebase?"

Have one or two real stories prepared per category. Use STAR (Situation, Task, Action, Result), but don't sound like a robot, these are conversations.

Questions To Ask Your Interviewer

You're evaluating them too. Strong questions:

  1. Which Laravel version are you on, and what's blocking the next upgrade?
  2. How do you handle queues, failed jobs, and background processing?
  3. What does the testing pyramid look like, unit vs feature vs end-to-end?
  4. How do you deploy, and what's your rollback story?
  5. What are the biggest performance bottlenecks today?
  6. How is technical debt tracked and prioritized?
  7. What does success look like in the first 90 days?
  8. Where does business logic live in the codebase?
  9. How is the team structured, feature teams, platform teams, both?

The right questions tell the interviewer you've shipped real software.

Final Checklist

By the day before the interview, you should be able to explain, out loud, without notes:

  1. The full request lifecycle from index.php to response.
  2. The difference between bind, singleton, and scoped.
  3. N+1 problems and at least three ways to fix them.
  4. How queues work, what happens when a job fails, and how Horizon helps.
  5. When to use events, jobs, and services, and how they relate.
  6. How to prevent race conditions with locks and transactions.
  7. The tradeoffs between Sanctum and Passport.
  8. How to design a clean API layer with resources, versioning, and rate limiting.
  9. At least three real performance optimizations you've actually shipped.
  10. A security checklist longer than just "use Eloquent."

Final Tips

The best Laravel interviews aren't trivia contests. They're conversations about how you build, scale, and operate real systems. Memorizing every config option won't help. Knowing why you'd reach for a singleton over a bind will.

A few last reminders:

  1. Talk through tradeoffs. Senior signals show up when you compare options out loud.
  2. Use real examples from your work. Concrete beats abstract every time.
  3. Admit what you don't know. "I haven't used that, here's how I'd reason about it" is a strong answer.
  4. Ask clarifying questions. Real engineering starts with understanding the problem.
  5. Keep code clean during live coding. Naming, small functions, and visible thought beat clever one-liners.

You've shipped Laravel apps. Talk like it. Go get the offer 👊


Originally published at nazarboyko.com.

Top comments (0)