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-2 days available. Focus on Request Lifecycle, Eloquent + N+1, Queues, Validation, Authorization. Skim everything else.
- 3-5 days available. Add Caching, Database Transactions, Testing, and API Design. Run through the senior scenarios at the end at least once.
- 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.
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
The path your request takes:
- Autoloader and bootstrap. Composer's autoloader runs, and
bootstrap/app.phpbuilds the application container. - Service providers register. Every provider's
register()method runs first. It only adds bindings, no resolving dependencies yet. - Service providers boot. Then
boot()runs on each provider. By this point all bindings exist, so you can resolve other services safely. - HTTP kernel handles the request. Global middleware runs (encrypt cookies, trim strings, CSRF for web routes).
- Route resolution. The router matches the URL, then runs route-level middleware (
auth,throttle,verified,signed,can:*). - Controller or route action runs. Dependencies inject through the service container.
- 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.

Likely interview question
"What's the difference between
register()andboot()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:
Route::middleware(['auth:sanctum', 'throttle:60,1'])
->prefix('v1')
->group(function () {
Route::apiResource('orders', OrderController::class);
Route::post('orders/{order}/refund', [RefundController::class, 'store']);
});
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:
authandauth:sanctum, authentication.throttle:60,1, rate limiting (60 requests per minute).verified, email verification gate.signed, verify signed URLs.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.
// Implicit binding — Laravel resolves Order from {order} via the model's primary key
Route::get('orders/{order}', function (Order $order) {
return $order;
});
Custom key:
// Resolve by slug instead of id
Route::get('posts/{post:slug}', function (Post $post) {
return $post;
});
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();
You can also override resolution per model:
public function resolveRouteBinding($value, $field = null): ?Model
{
return $this->where($field ?? 'slug', $value)
->whereNotNull('published_at')
->first();
}
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.
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);
}
The interview-tested distinction:
bind(), new instance each resolve. Use for stateful or cheap services.singleton(), one instance for the whole process. Use for expensive or shared services like an HTTP client or a config reader.scoped(), one instance per request. Useful in long-running setups like Octane, wheresingletoncan leak state between requests.

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'));
Drop this in an interview and you've signaled you've actually read the docs.
Constructor injection in the wild
final class ReportController
{
public function __construct(
private readonly ReportService $reports,
private readonly Logger $logger,
) {}
public function index(): JsonResponse
{
return response()->json(
$this->reports->generateMonthly()
);
}
}
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
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');
}
}
Relationship types worth knowing cold:
belongsTo,hasOne,hasMany,belongsToManyhasOneThrough,hasManyThroughmorphTo,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:
public function scopeRecent(Builder $query, int $days = 30): void
{
$query->where('placed_at', '>=', now()->subDays($days));
}
// Usage
$recent = Order::recent(7)->get();
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:
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();
}
}
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
use SoftDeletes;
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
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();

Tools to namedrop:
with(), eager load on the initial query.load(), eager load after the fact.loadMissing(), eager load only if not already loaded.withCount(), eager load relationship counts.withExists(), load a boolean for "has any related rows."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();
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));
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'],
]);
For anything serious, use Form Requests:
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,
]);
}
}
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?"
- 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.
- 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:
final class OrderPolicy
{
public function update(User $user, Order $order): bool
{
return $user->id === $order->user_id
|| $user->hasRole('admin');
}
}
// 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'));
}
Gates handle one-off authorization that doesn't tie to a model:
Gate::define('access-admin-panel', fn (User $user) => $user->is_admin);
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
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(),
]);
}
}
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.

Queue drivers
- Redis, most common production choice. Fast, supports delayed jobs, good Horizon integration.
- Database, fine for low volume; lock contention bites at scale.
- SQS / Beanstalkd, managed alternatives.
- 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
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();
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.
final class OrderPaid
{
public function __construct(public Order $order) {}
}
final class SendReceipt implements ShouldQueue
{
public function handle(OrderPaid $event): void
{
Mail::to($event->order->user)
->send(new ReceiptMail($event->order));
}
}
// Fire it
OrderPaid::dispatch($order);
In modern Laravel, listeners are auto-discovered if you put them in app/Listeners and type-hint the event in handle().

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:
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));
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()
);
Strategies
- Read-through,
Cache::remember()style. Miss the cache, fetch, store. - Write-through, update cache when you update the database.
- Cache-aside, application explicitly invalidates cache keys on writes.
- 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();

Atomic locks
For preventing duplicate operations:
$lock = Cache::lock("payment:{$order->id}", 10);
if ($lock->get()) {
try {
$this->charge($order);
} finally {
$lock->release();
}
}
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
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');
}
};
Migration tips for senior interviews:
- Always make migrations reversible.
- Add indexes deliberately, not reflexively.
- Never edit a deployed migration, write a new one.
- 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]);
});
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();
});
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();
}

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:
- B-tree (default), good for equality and range.
- Composite indexes, column order matters; left-most prefix rule.
- Unique indexes, also enforce data integrity.
- Partial / functional indexes (Postgres), index a subset or expression.
- 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);
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
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,
]);
}
}

Test types
- Unit tests, pure classes, no framework, fastest.
- Feature tests, full HTTP cycle with the test client.
- Integration tests, database, queues, events working together.
- Browser tests (Dusk), actual JavaScript interaction.
Helpful traits and tools
RefreshDatabase, wraps each test in a transaction; rollback after.DatabaseMigrations, re-runs migrations per test (slower, more isolated).WithFaker, faker instance for fixtures.- Mockery, mocking dependencies.
Bus::fake(),Queue::fake(),Mail::fake(),Event::fake(),Notification::fake(), assert dispatches without actually running them.
Parallel testing
php artisan test --parallel
Speeds up large suites. Make sure your tests don't share state or rely on hardcoded IDs.
API Design
Route::middleware(['auth:sanctum', 'throttle:api'])
->prefix('v1')
->group(function () {
Route::apiResource('orders', OrderController::class);
});
Resources
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(),
];
}
}
whenLoaded() is your N+1 prevention at the API layer, only includes the relation if it was eager-loaded.
Versioning
Two common approaches:
- URL versioning,
/api/v1/.... Simple, visible. - 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
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
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.
->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);
});
});
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
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:
- Singletons live across requests, state leaks are real.
- Static properties stick around between requests.
- Be deliberate with
scopedbindings instead ofsingleton. - 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
- Eager loading (
with,withCount). - Targeted indexes.
EXPLAINyour slow queries.- Avoid
SELECT *, use->select(['id', 'name']). - Read replicas for read-heavy workloads.
- Connection pooling (PgBouncer, ProxySQL) at scale.
Profiling tools
- Laravel Telescope, local dev request and query inspection.
- Laravel Pulse, production performance dashboard.
- Clockwork, alternative profiler.
- 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]);
Mass assignment
// Dangerous — accepts any field, including is_admin
User::create($request->all());
// Safe
User::create($request->validated());
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]);
Other senior-level mentions
- File upload validation (
image,mimes,max:size). - HTTPS enforcement and HSTS headers.
- Content Security Policy headers.
- Dependency scanning (
composer audit). - 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.

Service classes
A service holds business logic that doesn't belong on a model:
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;
});
}
}
Action classes
A more granular alternative, one class, one operation:
final class PlaceOrder
{
public function execute(User $user, array $data): Order
{
// ...
}
}
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:
- You have a non-relational data source (e.g., a remote API).
- You genuinely need to swap implementations.
- 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/
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:
- 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. - 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/tenancyautomate the connection switching. - 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:
- Controller, accept input, validate, hand off, return response.
- Service / action, execute a business operation.
- Event, broadcast that something happened.
- Listener, react to an event.
- 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);
});
Race conditions
Two requests, same row. Use:
lockForUpdate()for pessimistic locking inside a transaction.Cache::lock()for application-level coordination.- Unique indexes for "impossible-by-design" conflicts.
Long-running jobs
Long jobs need:
- Bounded execution time (
$timeout). - Memory awareness (chunk, lazy collections).
- Safe retries (
$tries, idempotency). - Progress reporting (database row, Redis key, or batch).
Designing a system from scratch
If asked "design X with Laravel", think out loud about:
- Bounded contexts and tables.
- Public API surface (routes and resources).
- Synchronous path (controller → service → DB → response).
- Async path (events → listeners → jobs).
- Failure modes and observability.
- Scaling concerns: caching, queues, replicas.
DevOps And Deployment
Zero-downtime deploys
The classic flow:
- Pull new code to a release directory.
composer install --no-dev.php artisan migrate --force(carefully).- Cache config, routes, views.
- Atomically symlink
currentto the new release. - Reload PHP-FPM (or restart Octane workers).
Tools: Envoyer, Forge, Deployer, GitHub Actions.
Migration safety in production
- Add a column nullable, backfill, then enforce.
- Avoid
dropColumnin the same release that stops using it; do it next release. - Index large tables concurrently (Postgres) or with low-impact tools (
pt-online-schema-changefor MySQL).
Task scheduling
Laravel's scheduler replaces a wall of crontab entries with code:
Schedule::command('reports:daily')->dailyAt('03:00');
Schedule::command('queue:prune-failed')->daily();
Schedule::job(new SyncInventoryJob)->hourly()->withoutOverlapping();
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:
- Horizon, queues.
- Telescope, local debugging.
- Pulse, production performance.
- Sentry / Bugsnag, exception tracking.
- Logs, Stackdriver, CloudWatch, Datadog.
Behavioral Questions That Actually Come Up
Senior interviews aren't only technical. Be ready for:
- "Tell me about a time you owned a system that failed in production."
- "How do you decide between fixing tech debt and shipping features?"
- "Describe a code review that changed your mind."
- "What's a Laravel decision your team made that you disagreed with?"
- "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:
- Which Laravel version are you on, and what's blocking the next upgrade?
- How do you handle queues, failed jobs, and background processing?
- What does the testing pyramid look like, unit vs feature vs end-to-end?
- How do you deploy, and what's your rollback story?
- What are the biggest performance bottlenecks today?
- How is technical debt tracked and prioritized?
- What does success look like in the first 90 days?
- Where does business logic live in the codebase?
- 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:
- The full request lifecycle from
index.phpto response. - The difference between
bind,singleton, andscoped. - N+1 problems and at least three ways to fix them.
- How queues work, what happens when a job fails, and how Horizon helps.
- When to use events, jobs, and services, and how they relate.
- How to prevent race conditions with locks and transactions.
- The tradeoffs between Sanctum and Passport.
- How to design a clean API layer with resources, versioning, and rate limiting.
- At least three real performance optimizations you've actually shipped.
- 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:
- Talk through tradeoffs. Senior signals show up when you compare options out loud.
- Use real examples from your work. Concrete beats abstract every time.
- Admit what you don't know. "I haven't used that, here's how I'd reason about it" is a strong answer.
- Ask clarifying questions. Real engineering starts with understanding the problem.
- 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 👊






