The 60 Laravel questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Laravel is an open-source PHP web framework created by Taylor Otwell and first released in 2011, built around the model-view-controller pattern and known for expressive, readable syntax. It ships with almost everything a web app needs out of the box: a router, the Eloquent ORM, database migrations, a queue system, authentication scaffolding, the Blade templating engine, and the Artisan command-line tool. That completeness is why teams pick it: you spend time on features, not plumbing. In interviews, Laravel questions probe understanding of the request lifecycle, the service container and dependency injection, Eloquent relationships and the N+1 query problem, and middleware, rather than memorized method names. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, the official Laravel documentation is the canonical reference; increasingly the first backend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Laravel PHP Framework Tutorial - Full Course for Beginners (2019)
Video: Laravel PHP Framework Tutorial - Full Course for Beginners (2019) (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Laravel certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Laravel is an open-source PHP web framework built on the MVC pattern, known for expressive syntax and a batteries-included toolset. It ships routing, the Eloquent ORM, migrations, queues, auth scaffolding, the Blade templating engine, and the Artisan CLI out of the box.
Teams choose it because that completeness cuts setup to near zero: you build features instead of assembling plumbing. Convention over configuration means new developers find their way quickly, and the first-party ecosystem (Forge, Vapor, Sanctum, Horizon) covers deployment, auth, and queues.
Key point: A one-line definition plus two concrete built-in examples beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Laravel Crash Course (Traversy Media, YouTube)
Models represent data and business rules (usually Eloquent classes mapped to tables), views handle presentation (Blade templates), and controllers coordinate: they receive the request, call models, and return a view or response.
The router sits in front, mapping a URL to a controller method. Keeping logic in models and services rather than controllers is the habit interviewers look for, because fat controllers are the classic maintainability trap.
How an MVC request moves through Laravel
Keep business logic in models or service classes, not controllers. Fat controllers are the maintainability trap interviewers probe for.
Key point: The where business logic belongs (models or dedicated service classes, not controllers). That distinction signals you've maintained real Laravel code.
Every request enters through public/index.php, which loads the autoloader and bootstraps the application from bootstrap/app.php. The HTTP kernel runs, global middleware execute, and the request is dispatched to the router.
The router matches a route, runs its route-specific middleware, resolves the controller (or closure) through the service container, and the controller returns a response. The response travels back out through middleware and is sent to the browser. Understanding this order explains where middleware, service providers, and the container each fit.
Key point: middleware run on the way in AND out.
Watch a deeper explanation
Video: What You Need to Know Before Learning Laravel | Learn Laravel The Right Way (Program With Gio, YouTube)
Artisan is Laravel's command-line tool. You use it to scaffold code (make:model, make:controller, make:migration), run migrations, clear caches, run the queue worker, start a dev server, and run custom commands you write yourself.
It saves boilerplate and standardizes the project structure. You can list every command with php artisan list, and write your own commands by extending the Command class.
php artisan make:model Post -mcr # model + migration + controller + resource
php artisan migrate # run pending migrations
php artisan route:list # inspect all registered routes
php artisan queue:work # process queued jobsKey point: The -mcr flag combo (model, migration, controller, resource in one command) is a small detail that indicates day-to-day fluency.
Routes live in the routes directory: web.php for stateful web routes (sessions, CSRF) and api.php for stateless API routes. You register them with the Route facade, one method per HTTP verb.
Routes can point to a closure or a controller method, accept parameters, and be named for URL generation. Grouping lets you share middleware, prefixes, or namespaces across many routes.
use App\Http\Controllers\PostController;
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::post('/posts', [PostController::class, 'store'])->middleware('auth');Route model binding automatically injects a model instance into your controller based on a route parameter. If a route has {post}, Laravel resolves the Post model whose ID matches and passes it in, throwing a 404 if none exists.
Implicit binding matches by primary key by default; you can bind by another column with getRouteKeyName() or in the route definition. It removes repetitive findOrFail() calls.
// route: Route::get('/posts/{post}', ...);
public function show(Post $post)
{
// $post is already resolved from the {post} id, or 404 if missing
return view('posts.show', compact('post'));
}Key point: Volunteering the 404-on-miss behavior and binding-by-slug variant shows you've used it beyond the happy path.
Watch a deeper explanation
Video: Laravel PHP Framework Tutorial - Full Course for Beginners (2019) (freeCodeCamp.org, YouTube)
Eloquent is Laravel's ActiveRecord ORM. Each model class maps to a database table, and each instance maps to a row. By convention a Post model maps to a posts table, so you rarely configure the mapping explicitly.
Eloquent gives you expressive methods for CRUD, relationships, scopes, and events without writing SQL. You can still drop to the query builder or raw SQL when you need to.
class Post extends Model
{
// maps to 'posts' table by convention
}
$post = Post::create(['title' => 'Hello', 'body' => 'World']);
$latest = Post::where('published', true)->latest()->first();
$post->update(['title' => 'Updated']);The query builder is a fluent interface over raw SQL: DB::table('posts')->where(...)->get() returns plain rows (stdClass or arrays). Eloquent sits on top and returns model objects with relationships, casts, accessors, and events.
Reach for Eloquent when you want the object model and its conveniences, which is most application code. Drop to the query builder for bulk operations, complex reporting joins, or hot paths where hydrating full models is wasteful.
| Eloquent | Query builder | |
|---|---|---|
| Returns | Model objects | Plain rows (stdClass/array) |
| Relationships | Yes | Manual joins |
| Events, casts, scopes | Yes | No |
| Best at | Application logic | Bulk ops, heavy reporting |
Key point: Saying 'Eloquent for app logic, query builder for bulk and reporting' is the balanced answer that avoids sounding dogmatic either way.
Migrations are version-controlled PHP files that describe schema changes: creating tables, adding columns, indexes, and foreign keys. Running php artisan migrate applies pending ones; rollback reverses them via the down() method.
They let a whole team and every environment reach the same schema from code, without passing SQL scripts around. Combined with the schema builder, they keep database changes reviewable in pull requests like any other code.
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->boolean('published')->default(false);
$table->timestamps();
});
}Blade is Laravel's templating engine. Templates compile to plain PHP and are cached, so there's no runtime overhead. It gives you clean directives (@if, @foreach, @auth), automatic output escaping with {{ }}, template inheritance, components, and slots.
The escaping matters: {{ $value }} escapes HTML by default to prevent XSS, while {!! $value !!} outputs raw and should be used only on trusted content. Components and layouts keep markup DRY across pages.
@extends('layouts.app')
@section('content')
@foreach ($posts as $post)
<h2>{{ $post->title }}</h2> {{-- escaped output --}}
@endforeach
@endsectionKey point: Mentioning that {{ }} auto-escapes while {!! !!} does not is exactly the security awareness this question screens for.
Middleware is a layer that inspects or filters HTTP requests as they enter and responses as they leave. Laravel ships middleware for authentication, CSRF protection, and trimming input; you assign them globally, to route groups, or to single routes.
A classic example is auth, which redirects unauthenticated users to login. You write custom middleware with make:middleware, put logic before or after $next($request), and register it in the kernel or route.
public function handle(Request $request, Closure $next)
{
if (! $request->user()?->isAdmin()) {
abort(403);
}
return $next($request); // pass to the next layer
}Configuration lives in the config directory as PHP files that return arrays. Those files read environment-specific values from the .env file through the env() helper, so secrets and per-environment values stay out of version control.
The rule: call env() only inside config files, and read values everywhere else through config('services.key'). That's because config caching (config:cache) freezes config and env() returns null outside it.
Key point: Knowing that env() returns null after config:cache unless it's called from a config file is a real production gotcha interviewers respect.
A facade is a static-looking proxy to a service resolved from the container. Cache::get() looks like a static call, but under the hood it resolves the cache service from the container and calls the method on that instance.
Facades give a clean, memorable API while keeping the real object testable and swappable. You can always inject the underlying class instead if you prefer explicit dependencies, which some teams do for clarity.
use Illuminate\Support\Facades\Cache;
Cache::put('key', 'value', now()->addMinutes(10));
$value = Cache::get('key');
// equivalent via the container:
// app('cache')->put(...)Relationships define how models relate in the database and let you traverse them as properties. The common ones are hasOne, hasMany, belongsTo, belongsToMany (many-to-many via a pivot table), and the hasManyThrough and polymorphic variants for indirect and shared relations.
You declare them as methods on the model, then access them as dynamic properties. Laravel builds the joining queries for you based on naming conventions and the keys you specify.
class User extends Model
{
public function posts()
{
return $this->hasMany(Post::class);
}
}
class Post extends Model
{
public function author()
{
return $this->belongsTo(User::class, 'user_id');
}
}
$user->posts; // collection of Post
$post->author; // the owning UserThe quickest way is $request->validate() in a controller, passing an array of rules; on failure Laravel redirects back with errors for web requests or returns a 422 JSON response for API requests. For anything reusable, put the rules in a Form Request class.
Rules are expressive strings or arrays (required, email, max:255, exists:users,id), and you can add custom rules and messages. Validation runs before your business logic, so invalid data never reaches it.
$validated = $request->validate([
'title' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'age' => 'nullable|integer|min:18',
]);
// $validated holds only the validated fieldsTinker is an interactive REPL for your application, started with php artisan tinker. It boots the full app so you can run Eloquent queries, call services, and inspect objects live, exactly as they'd run in a request.
It's ideal for quickly checking a relationship, seeding a test record, or debugging why a query returns nothing, without writing a throwaway route.
php artisan tinker
>>> User::count()
>>> User::first()->posts
>>> Post::factory()->count(5)->create()A Collection is a fluent, object-oriented wrapper around arrays, returned by most Eloquent queries. It gives you chainable methods (map, filter, reduce, pluck, groupBy, sortBy) that read far better than nested array functions.
Because methods return new collections, you can pipeline transformations cleanly. Eloquent results are collections, so the same tools work on database rows and plain arrays alike.
$names = Post::all()
->filter(fn ($p) => $p->published)
->sortByDesc('created_at')
->pluck('title');
// chainable, readable, returns a CollectionFactories define how to generate fake model instances with realistic data, using Faker. Seeders use factories (or explicit data) to populate the database, run via php artisan db:seed. Together they give you repeatable test and demo data.
This pairing is what makes testing practical: a test can spin up ten users with posts in one line, run against them, and roll back, without hand-writing fixtures.
// database/factories/PostFactory.php
public function definition(): array
{
return [
'title' => fake()->sentence(),
'body' => fake()->paragraph(),
];
}
// usage
Post::factory()->count(10)->create();Laravel generates a per-session CSRF token and expects it on every state-changing request (POST, PUT, PATCH, DELETE). The VerifyCsrfToken middleware checks it; a mismatch returns a 419 error. In forms you output it with the @csrf directive.
For AJAX, the token goes in a header (X-CSRF-TOKEN), often read from a meta tag. API routes typically use token or Sanctum auth instead of session CSRF, so they can be excluded.
<form method="POST" action="/posts">
@csrf
<input name="title">
<button>Save</button>
</form>Type-hint Illuminate\Http\Request in the controller method and Laravel injects it. Then read input with $request->input('key'), $request->only([...]), $request->all(), query params with $request->query(), and files with $request->file('avatar').
Prefer $request->input() or the validated data over raw superglobals, because the Request object normalizes JSON and form bodies and integrates with validation.
public function store(Request $request)
{
$title = $request->input('title');
$tags = $request->input('tags', []); // default if missing
$file = $request->file('cover');
}A named route attaches a stable name to a URL so you generate links by name, not by hardcoding the path. Then route('posts.show', $post) builds the URL, and changing the path later doesn't break every reference.
Named routes also power redirects (redirect()->route('login')) and make route definitions self-documenting. It's the difference between refactor-safe links and a find-and-replace hunt.
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
// generate the URL anywhere:
$url = route('posts.show', $post); // /posts/42Sessions store data across requests for a user, backed by a driver (file, cookie, database, Redis). You read and write with session('key') or the request. Flash data is session data that lives for exactly the next request, which is how success and error messages survive a redirect.
After a form submit you typically redirect with a flashed message and show it once on the next page. Laravel clears it automatically, so it doesn't reappear.
return redirect()
->route('posts.index')
->with('status', 'Post created');
// in the Blade view:
// @if (session('status')) {{ session('status') }} @endifComposer is PHP's dependency manager, and Laravel itself is installed through it. It reads composer.json to know which packages a project needs, resolves compatible versions, and installs them into the vendor directory. The composer.lock file pins exact versions so every machine gets the same set.
It also generates the autoloader that lets you use any class without manual require calls. Running composer install rebuilds vendor from the lock file; composer update recomputes versions within your constraints.
composer create-project laravel/laravel my-app # new project
composer require laravel/sanctum # add a package
composer install # install from lock fileFor quick inspection there's dd() (dump and die, stops execution and shows a formatted value) and dump() (shows a value and keeps going). For anything that should persist, use the Log facade, which writes to storage/logs through configurable channels and levels.
In development you can add Telescope or Debugbar for request, query, and job insight. In production you send logs to a channel (a file, Slack, or an external service) at the right level, so you have a trail without dd() ever reaching users.
dd($user->toArray()); // dump and stop
dump($request->all()); // dump and continue
Log::info('Order placed', ['id' => $order->id]);
Log::error('Payment failed', ['user' => $user->id]);Key point: Saying you'd never leave a dd() in committed code, and reach for Log for anything real, is the small maturity signal here.
For candidates with working experience: Eloquent depth, the container, queues, and the questions that separate users from understanders.
N+1 happens when you load a collection of N parents, then access a relationship inside a loop, firing one extra query per parent: 1 query for the parents plus N for the relations. It's the most common Laravel performance bug and it hides in views that look innocent.
The fix is eager loading with with(): Laravel loads all the related records in one additional batched query instead of N. Use load() when you already have the collection, and lazy() or chunking for huge datasets.
// N+1: 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // one query per post
}
// fixed: 2 queries total
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // already loaded
}Key point: Naming how you'd DETECT it (Laravel Debugbar, the query log, or preventLazyLoading in dev) is as valued as naming the fix.
Lazy loading fetches a relationship the first time you access it, one query at a time, which causes N+1 in loops. Eager loading fetches the relationships up front with with(), in a small fixed number of queries.
Lazy eager loading with load() sits in between: you already have the parent collection and want to load a relation on it in one query. Choose eager loading whenever you know you'll touch the relation for many records.
| Lazy | Eager (with) | Lazy eager (load) | |
|---|---|---|---|
| When it queries | On first access | Up front | After retrieval, on demand |
| Query count for N parents | 1 + N | 2 | 2 |
| Best at | Single record | Loops over many | Already-fetched collection |
The service container is Laravel's engine for resolving classes and their dependencies. When you type-hint a class in a constructor or controller method, the container builds it, recursively resolving whatever it needs. You rarely call new for services.
You teach it how to build things with bindings in a service provider: bind() for a fresh instance each time, singleton() for one shared instance, and interface-to-implementation bindings so you can swap concretes without touching callers.
// in a service provider
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// anywhere, the container injects StripeGateway:
public function __construct(private PaymentGateway $gateway) {}Key point: Explaining bind vs singleton plus interface binding for swappability is the answer that indicates 'has actually structured an app', not just used one.
Service providers are the bootstrapping classes where you wire services into the container. They have two phases: register() only binds things into the container, and boot() runs after every provider is registered, so it can safely use other services.
The rule that trips people up: never resolve a service inside register(), because the thing you need may not be bound yet. Event listeners, view composers, and route model bindings belong in boot().
public function register(): void
{
$this->app->singleton(Reporter::class); // only bind here
}
public function boot(): void
{
View::share('appName', config('app.name')); // safe to use services here
}Key point: The 'don't resolve services in register()' rule is a common trip-up. Stating it in practice signals real provider experience.
Queues push slow work (sending email, processing images, calling third-party APIs) out of the request cycle so responses stay fast. You dispatch a job class, a queue worker (php artisan queue:work) picks it up and runs it in the background against a driver like Redis, database, or SQS.
Jobs are simple classes with a handle() method that can be retried, delayed, rate-limited, and batched. Failed jobs land in a failed_jobs table for inspection and retry.
class SendWelcomeEmail implements ShouldQueue
{
public function __construct(public User $user) {}
public function handle(): void
{
Mail::to($this->user)->send(new WelcomeMail($this->user));
}
}
SendWelcomeEmail::dispatch($user)->onQueue('mail');How a queued job flows
The point is decoupling: the user's request never waits on the slow work.
Events let you announce that something happened (OrderShipped) without the code that reacts being coupled to the code that fired it. Listeners subscribe to events and run the reaction; one event can have many listeners.
Use them to decouple side effects: fire OrderPlaced, and separate listeners send email, update inventory, and notify Slack, each testable in isolation. Queued listeners move that work off the request. Overusing events for straight-line logic hurts readability, so reach for them when decoupling genuinely helps.
event(new OrderPlaced($order));
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->customer)->send(new OrderConfirmed($event->order));
}
}A Form Request is a dedicated class that holds validation rules and authorization for a specific request. You type-hint it in the controller method, and Laravel validates automatically before the method body runs, returning errors on failure.
It keeps controllers thin, makes rules reusable and testable, and puts authorization (authorize()) next to validation. For anything beyond a couple of inline rules, this is the maintainable choice.
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return ['title' => 'required|max:255', 'body' => 'required'];
}
}
// controller
public function store(StorePostRequest $request) { /* already validated */ }API Resources are transformer classes that shape an Eloquent model into a JSON structure. They decouple your database schema from your API contract, so you choose exactly which fields appear, rename them, add computed values, and nest relations, without leaking internal columns.
They also handle collections and pagination consistently. Without them you either expose raw models (fragile, over-sharing) or hand-build arrays (repetitive).
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => $this->whenLoaded('author', fn () => $this->author->name),
];
}
}
return PostResource::collection($posts);Laravel offers session-based auth for traditional web apps (starter kits like Breeze and Jetstream scaffold it), Sanctum for SPA and mobile token auth on first-party clients, and Passport for full OAuth2 when third parties need to authorize.
The judgment call: Breeze or Jetstream for server-rendered apps, Sanctum for your own SPA or mobile app hitting your API, Passport only when you actually need OAuth2 authorization flows. Reaching for Passport by default is the common overkill mistake.
| Tool | Best for | Auth style |
|---|---|---|
| Breeze / Jetstream | Server-rendered web apps | Session cookies |
| Sanctum | First-party SPA and mobile | API tokens / cookie |
| Passport | Third-party OAuth2 clients | Full OAuth2 server |
Key point: Saying 'Sanctum for first-party, Passport only when you need OAuth2' is the concise answer that avoids the reach-for-Passport reflex.
Watch a deeper explanation
Video: Laravel 8 REST API With Sanctum Authentication (Traversy Media, YouTube)
Both handle authorization. Gates are closures for simple, one-off checks not tied to a model (can this user access the admin dashboard?). Policies are classes that group authorization logic around a specific model, one method per action (view, update, delete a Post).
Use policies when authorization is per-model and has several actions, which is most CRUD. Use gates for coarse, model-free checks. Both plug into the same $user->can(...) and authorize() calls.
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
}
// in a controller
$this->authorize('update', $post); // 403 if the policy deniesScopes package reusable query constraints on a model. A local scope is a method prefixed with scope that you call fluently (Post::published()), keeping common where-clauses in one place instead of repeating them across the codebase.
Global scopes apply a constraint to every query on a model automatically, which is how soft deletes and multi-tenant filtering work. The trade-off with global scopes is that they can surprise you, so they're worth documenting.
class Post extends Model
{
public function scopePublished($query)
{
return $query->where('published', true);
}
}
Post::published()->latest()->get();Accessors transform an attribute when you read it, mutators transform it when you write it, letting a model present clean values while storing raw ones. Casts do the same declaratively for common types: casting a column to boolean, array, datetime, or an enum.
Modern Laravel unifies accessor and mutator into a single Attribute method. Casts handle the routine conversions (JSON columns to arrays, timestamps to Carbon), while accessors handle custom logic like formatting a full name.
protected function casts(): array
{
return ['published' => 'boolean', 'meta' => 'array', 'published_at' => 'datetime'];
}
protected function fullName(): Attribute
{
return Attribute::get(fn () => "{$this->first} {$this->last}");
}A many-to-many relationship (a User has many Roles and a Role has many Users) needs a pivot table in the middle, conventionally named from the two singular table names alphabetically (role_user). You declare belongsToMany on both models.
The pivot table can carry extra columns (like assigned_at), which you expose with withPivot(). Attach, detach, and sync manage the links: sync() replaces the whole set to match an array of IDs, which is what checkbox forms usually want.
class User extends Model
{
public function roles()
{
return $this->belongsToMany(Role::class)->withPivot('assigned_at');
}
}
$user->roles()->attach($roleId);
$user->roles()->sync([1, 2, 3]); // set roles to exactly theseSoft deletes mark a row as deleted with a deleted_at timestamp instead of removing it. Add the SoftDeletes trait and a deleted_at column, and Eloquent automatically hides soft-deleted rows from normal queries via a global scope.
You recover them with restore(), include them with withTrashed(), or query only them with onlyTrashed(). Use forceDelete() to remove permanently. It's the standard way to keep an audit trail and allow undo.
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes; // needs a deleted_at column
}
$post->delete(); // sets deleted_at
Post::withTrashed()->find($id); // include soft-deleted
$post->restore(); // bring it backDB::transaction() wraps a set of queries so they all commit together or all roll back on any exception, keeping data consistent. It's the right tool whenever a single logical operation touches multiple tables, like creating an order and its line items.
The closure form auto-commits on success and auto-rolls-back if an exception is thrown, and it retries on deadlock a configurable number of times. For manual control there's DB::beginTransaction(), commit(), and rollBack().
DB::transaction(function () use ($cart) {
$order = Order::create(['total' => $cart->total()]);
$order->items()->createMany($cart->lineItems());
Inventory::decrement($cart->skus());
// any exception here rolls the whole thing back
});The Cache facade gives a unified API over drivers (Redis, Memcached, database, file). You store values with a TTL, retrieve them, and the remember() helper computes-and-caches in one call: if the key is missing it runs the closure, stores the result, and returns it.
Beyond the application cache, Laravel caches config, routes, views, and events for production speed. The eternal caching problem stays: invalidation. Key cache entries carefully and clear or tag them when the underlying data changes.
$stats = Cache::remember('dashboard.stats', now()->addMinutes(10), function () {
return expensiveDashboardQuery();
});
Cache::forget('dashboard.stats'); // invalidate on data changeLaravel ships with a testing setup on top of PHPUnit (and Pest as a popular alternative). Feature tests exercise real HTTP routes with helpers like $this->get('/posts')->assertOk(), while unit tests check classes in isolation. Factories and a fresh test database make each test self-contained.
The RefreshDatabase trait migrates and rolls back around each test so state never leaks. You assert on responses, database rows (assertDatabaseHas), sent mail, dispatched jobs, and events, all with expressive helpers.
public function test_a_post_can_be_created(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', ['title' => 'Hi', 'body' => 'There'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['title' => 'Hi']);
}Laravel's scheduler lets you define recurring tasks in code (in the console schedule) with a fluent frequency API: daily(), everyFiveMinutes(), cron() for custom expressions. You register a single cron entry on the server that runs schedule:run every minute, and Laravel decides what's due.
This beats scattering many crontab lines: schedules live in version control, can be conditional, avoid overlaps with withoutOverlapping(), and run on one queue. Tasks can be Artisan commands, closures, or queued jobs.
// one server crontab line:
// * * * * * cd /app && php artisan schedule:run >> /dev/null 2>&1
$schedule->command('reports:daily')->dailyAt('02:00');
$schedule->job(new PruneOldLogs)->weekly()->withoutOverlapping();Collections give a chainable, readable API over the same data an array holds. Instead of nesting array_map inside array_filter and losing the reading order, you write a top-to-bottom pipeline of map, filter, and reduce that mirrors how you think about the transformation.
They also carry helpful methods arrays lack (groupBy, pluck, partition, sum by key) and return collections so chaining stays fluent. On huge datasets, LazyCollection streams instead of holding everything in memory.
collect($orders)
->groupBy('customer_id')
->map(fn ($group) => $group->sum('total'))
->sortDesc();config:cache merges every config file into one cached file so the framework doesn't parse them all on each request; route:cache compiles route registration into a fast lookup. Both cut per-request overhead noticeably and belong in production deploys.
The catch: after config:cache, env() returns null outside config files, and route:cache breaks if you use closure-based routes (they can't serialize). Clear them in development with config:clear and route:clear so changes take effect.
Key point: Naming the two gotchas (env() null after config cache, no closure routes with route cache) is what separates 'ran the command once' from 'debugged it in production'.
advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
When you ask the container for a class, it checks for an explicit binding first; if none exists it uses reflection to inspect the constructor, recursively resolves each type-hinted dependency, and instantiates the class. Singletons are cached after the first build; regular bindings rebuild each time.
Contextual bindings let the same interface resolve to different concretes depending on the consuming class, and interface-to-implementation bindings make the whole app swappable. Understanding this is why you can type-hint an interface and never call new.
Key point: reflection-based autowiring and contextual bindings matters.
Middleware belongs to HTTP concerns that wrap the request: auth, rate limiting, header manipulation, logging. Events and listeners belong to decoupled side effects triggered by domain actions (order placed sends mail, updates inventory). Service classes hold the core business logic that controllers call.
The failure mode is stuffing business logic into middleware because it's convenient, or firing events for straight-line code that would read better as a direct call. Match the tool to whether the concern is transport, a decoupled reaction, or the operation itself.
It depends, and having a real opinion matters here. Eloquent is already an abstraction over the database, so wrapping every model in a repository often adds a layer that hides Eloquent's expressive query methods and buys little, since you'll almost never swap the ORM.
Where a repository or a query-object earns its place: consolidating a genuinely complex query used in many spots, or defining a domain boundary in a large app where you want to keep Eloquent out of the domain layer. Blanket repositories over simple CRUD are usually cargo-culted from other ecosystems.
Key point: A crisp 'usually no, here's when yes' beats a rehearsed pro-repository sermon; the question needs to see you weigh trade-offs.
Never load everything with get(): it hydrates every row into memory at once. Use chunk() or chunkById() to process fixed-size batches, cursor() to stream one hydrated model at a time via a generator, or lazy() for a LazyCollection you can pipeline.
chunkById() is safer than chunk() when you're modifying rows during iteration, because plain chunk() paginates by offset and skips or repeats rows when the result set shifts under you. For pure reads, cursor() keeps memory flat.
User::where('active', true)->chunkById(1000, function ($users) {
foreach ($users as $user) {
ExportUser::dispatch($user);
}
});
// streaming alternative, one model at a time:
foreach (User::lazy() as $user) { process($user); }Key point: Knowing WHY chunkById beats chunk when mutating rows (offset drift) is the detail that marks real large-dataset experience.
Make jobs idempotent so a retry can't double-charge or double-send, set sensible tries and backoff, and use a durable driver (Redis or SQS, not sync) with a supervised worker (Supervisor or Horizon) that restarts on crash. Monitor the failed_jobs table and alert on it.
Guard against long jobs blocking the queue with timeouts, prevent duplicate concurrent runs with WithoutOverlapping or unique jobs, and separate high and low priority work onto different queues so a flood of one doesn't starve the other.
| Risk | Mitigation |
|---|---|
| Double processing on retry | Idempotent handle() logic |
| Worker crashes | Supervisor / Horizon restarts it |
| One queue starves others | Priority queues, dedicated workers |
| Silent failures | Monitor failed_jobs, alerting |
Horizon is a dashboard and configuration layer for Redis-backed queues. It gives real-time metrics (throughput, wait times, failures), tag-based job monitoring, auto-balancing of workers across queues, and code-driven worker configuration instead of hand-tuned Supervisor files.
Reach for it once queues are load-bearing: you want visibility into what's slow, alerts on failures, and the ability to scale workers per queue by config. On a small app with light queues, plain queue:work under Supervisor is enough.
Select only the columns you need instead of full rows, add database indexes on the columns you filter and join on, and use exists() or count() instead of loading collections just to check presence. Constrain eager loads with closures so you don't pull relations you won't use.
For aggregates, push work to the database (withCount, withSum, groupBy) rather than summing in PHP. For read-heavy hot paths, cache the computed result. And always confirm with the query log or a profiler rather than guessing which query is the bottleneck.
$authors = User::select('id', 'name')
->withCount('posts')
->with(['posts' => fn ($q) => $q->select('id', 'user_id', 'title')->latest()->limit(5)])
->get();A polymorphic relationship lets one model belong to more than one other model type on a single association. A Comment can belong to a Post or a Video through a morphTo relation, using two columns: commentable_id and commentable_type.
They fit genuinely shared concerns: comments, likes, images, tags across many parent types. The cost is weaker foreign-key integrity (the type column isn't a real FK) and trickier queries, so use them when the shared behavior is real, not just to avoid a couple of tables.
class Comment extends Model
{
public function commentable()
{
return $this->morphTo();
}
}
class Post extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}Implement CastsAttributes with get() and set() to convert a stored value to and from a rich object. This centralizes a transformation so every place that touches the attribute gets the object form (a value object, an encrypted field, a money type) without repeating logic.
It's cleaner than accessors and mutators when the same conversion applies across many models, and it keeps the model's storage concern separate from how the rest of the app uses the value.
class MoneyCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
return new Money($value); // cents to Money object
}
public function set($model, $key, $value, $attributes)
{
return $value->cents(); // Money object back to cents
}
}
// model: protected $casts = ['price' => MoneyCast::class];Many Laravel classes (Collection, Str, Request, the query builder, Response) are macroable: you register a closure with ::macro() and it becomes callable as if it were a native method. It's the sanctioned way to add reusable helpers to framework classes without extending them.
Register macros in a service provider's boot method. Use them for genuinely reusable extensions (a domain-specific collection method used everywhere), but keep them discoverable and documented, since a macro that appears from nowhere confuses the next developer.
// in a service provider boot()
Collection::macro('toUpper', function () {
return $this->map(fn ($v) => strtoupper($v));
});
collect(['a', 'b'])->toUpper(); // ['A', 'B']Clarify the isolation requirement first, then pick a model. Single database with a tenant_id column and a global scope is simplest and cheapest, isolation is logical. A database per tenant gives stronger isolation and easier per-tenant backups at higher operational cost. A schema per tenant sits in between on Postgres.
For the shared-database approach, a global scope plus a resolved current-tenant binding keeps every query filtered automatically, and you guard against cross-tenant leaks in tests. The right choice depends on compliance needs, tenant count, and how customizable per tenant the data must be.
| Strategy | Isolation | Cost / complexity |
|---|---|---|
| Shared DB + tenant_id | Logical (scope) | Low |
| Schema per tenant | Stronger | Medium |
| Database per tenant | Strongest | High (migrations, backups per tenant) |
Key point: Leading with 'what isolation and compliance do we need?' before naming a strategy is exactly the design-question behavior the technical evaluation checks.
Version at the URL prefix (v1, v2) or via a header, keeping controllers and API Resources namespaced per version so a breaking change lives in its own set of classes. Route groups apply shared middleware, and API Resources pin the response contract so schema changes don't silently break clients.
Structure the app so business logic lives in service or action classes that both versions can call, and only the thin controller and resource layer differs. Deprecate old versions on a schedule with clear communication rather than maintaining them forever.
In development, enable Model::preventLazyLoading() so any lazy load throws immediately, turning silent N+1 into a loud test failure. Laravel Debugbar or Telescope surface query counts and durations per request, and DB::listen or enableQueryLog() logs the actual SQL.
In CI, assert query counts on critical endpoints so a regression fails the build. In production, Telescope (guarded) or an APM tool flags slow queries. The theme: make the invisible visible early, because query problems rarely show up until data grows.
// in AppServiceProvider boot(), non-production only
Model::preventLazyLoading(! app()->isProduction());
// assert query count in a test
DB::enableQueryLog();
$this->get('/dashboard');
$this->assertLessThan(10, count(DB::getQueryLog()));Eloquent fires events around a model's lifecycle (creating, created, updating, saving, deleting). Observers group listeners for these into one class, so you can auto-set a slug on creating or clean up files on deleting without cluttering the model.
The risk is hidden side effects: logic in observers fires on every save from anywhere, including seeders, tinker, and bulk operations, and mass updates like Model::where(...)->update() bypass model events entirely. Overloaded observers make behavior hard to trace, so keep them thin and know what skips them.
Key point: Naming that query-builder bulk updates skip model events is the gotcha that catches people who rely on observers for critical invariants.
Observe first: check logs and any APM for the slow route, then look at query count and duration for that request (Telescope, slow-query log). The usual suspects are N+1 queries, a missing index, an un-cached expensive computation, or a synchronous call to a slow external service that should be queued.
Form a hypothesis, confirm it with a measurement (not a guess), fix at the right layer, and verify the improvement with the same measurement. The structure, observe, hypothesize, verify, fix, confirm, matters more than any single tool.
Key point: The methodology is; tools support the evidence.
Clarify scale and features first (redirect volume, custom aliases, analytics, expiry). Core model: a links table storing the short code and target URL with a unique index on the code. Generation can be a base62 encoding of the auto-increment id or a random collision-checked code; encoding the id avoids lookups.
The redirect route resolves the code to a URL and issues a 301 or 302. Put the code lookup behind a cache (Redis) since it's read-heavy, queue click-tracking so the redirect stays fast, and rate-limit creation. Close on the operational edges: reserved words, expired links, and what to serve on a miss.
Route::get('/{code}', function (string $code) {
$url = Cache::remember("link:$code", 3600, fn () =>
Link::where('code', $code)->value('target')
);
abort_if(! $url, 404);
TrackClick::dispatch($code); // queued, non-blocking
return redirect()->away($url, 301);
});Key point: Structuring the answer clarify, data model, generation, redirect path, operational edges is what the design question is really scoring.
Laravel wins when you want a full-stack PHP framework that gives you productivity out of the box: convention over configuration, a rich ORM, and first-party tooling for queues, auth, and testing. It trades some of Symfony's configurability for speed of delivery, and it runs on PHP, which shapes hiring and hosting. Where it competes with Rails, the mental model is close (both are opinionated, ActiveRecord-style, convention-heavy). Against Node/Express, Laravel gives you structure and an ORM by default, while Express hands you a minimal core and asks you to assemble the rest. Saying these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Framework | Language | Best at | Watch out for |
|---|---|---|---|
| Laravel | PHP | Full-stack apps, fast delivery, rich ORM | PHP runtime constraints, heavier than micro-frameworks |
| Symfony | PHP | Configurable enterprise systems, reusable components | Steeper setup, more boilerplate |
| Ruby on Rails | Ruby | Convention-driven apps, mature ecosystem | Ruby hosting/hiring pool, runtime speed |
| Express (Node) | JavaScript | Lightweight APIs, shared JS stack | No ORM or structure by default, assemble it yourself |
Prepare in layers, and practice out loud. Most Laravel rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Laravel interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Laravel questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works