Building Web Apps with Laravel Module 19 – Interview preparation and timed practice
Laravel interview concepts and question bank
Answer Laravel interview questions with the mechanism, a trade-off and a recorded run, on the container, middleware, N+1 queries, transactions and security.
What you will learn
- Structure an answer as a definition, the mechanism, one trade-off, a failure mode and a proof
- Explain bind, singleton and scoped bindings, facades and middleware order with a recorded run
- Predict query counts and rollback outcomes in Eloquent code, and prove them with the query log
- Answer request-forgery, XSS, mass-assignment and IDOR questions with Laravel's own classes and methods
Before you start
What you’ll learn · Before you start
What you will learn
- Structure an answer as a definition, the mechanism, one trade-off, a failure mode and a proof
- Explain bind, singleton and scoped bindings, facades and middleware order with a recorded run
- Predict query counts and rollback outcomes in Eloquent code, and prove them with the query log
- Answer request-forgery, XSS, mass-assignment and IDOR questions with Laravel's own classes and methods
Before you start
On this page
An interviewer shows you a loop over ten batches that prints each batch’s teacher, and asks how many SQL queries it sends. Run on Laravel 13.34.0, that loop sent 11, and the same loop with with('teacher') sent 2: a number you can predict, explain and then prove is the strongest answer you can give.
This lesson trains that kind of answer on six topics: the shape of a strong answer, the service container and facades, middleware, queries, transactions with queued jobs, and security. Each section shows a short program first, so you can write down your prediction, and then the output it printed when it ran. The questions at the end are original and graded by level.
A strong answer names the mechanism and how it fails
Take the question “What is a facade?”. A common reply is “a static class you call with ::”, and the next question exposes it: if it is static, how do you swap it for a fake in a test? The reply describes the syntax, not the thing.
A strong answer has four parts, and a fifth that you bring with you:
- A definition in one exact sentence.
- The mechanism: the class or method that does it in Laravel, by name.
- One trade-off: what the choice costs, and when you would choose otherwise.
- A failure mode: how it breaks, and how you would see it break.
- The proof: a run or a test whose output shows the claim.
Step through the facade answer built this way. Each step adds what the reply above lacked:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The question. An interviewer asks what a facade is. The common weak answer, "a static class", is wrong, because only the call looks static. Each part below fixes one thing that answer lacks.
- A definition in one sentence. A facade is a class whose static-looking calls are forwarded to one object that the service container builds. One sentence, and every word of it can be checked.
- The mechanism, by name. Name the parts: Cache extends Illuminate\Support\Facades\Facade, and its getFacadeAccessor() returns 'cache', the name of a container binding. The call runs on that object.
- One trade-off. Calls are short and each facade can be swapped for a fake in a test. The cost is that the dependency is not in the constructor, so a class can collect many without anyone seeing it.
- A real failure mode. Called before the application exists, a facade has nothing to forward to. The probe's first line threw "A facade root has not been set." This happens in a plain script that never boots the app.
- The proof. The probe compared Cache::getFacadeRoot() with app('cache') and printed "same object". That line turns the definition from a claim you remember into a fact you showed.
The first four parts can come from any page of the documentation, and the proof is what you bring. Each section below gives you one: a probe of a few lines whose output you can quote. The container probe in the next section supplied the facade answer’s failure mode and its proof, so you can check both lines yourself.
Key idea
An answer that names the class, the cost and the way it fails, and then shows a run, has already answered the next three questions.
Checkpoint
Check: The parts of a strong answer
What the container returns: bind, singleton and scoped
Ask the service container: The object that builds and stores the classes an application needs. Laravel's application object is the container, and providers put their bindings into it. More in “How Laravel handles a request” for the same thing twice, and what you get back depends on how it was registered. The probe below registers one small class three ways, asks for each twice, and then starts a new lifecycle the way a queue worker does before each job. Write down your prediction for every line before you read the run:
<?php
// Predict the output: what Laravel's service container hands back when you ask for the same thing
// twice. spl_object_id() names an object, so equal ids mean one object. The first line calls a facade
// before the application exists. Run: php container.php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Cache;
require __DIR__.'/vendor/autoload.php';
function show(string $case, string $result): void
{
printf("%-34s %s\n", $case, $result);
}
function same(object $a, object $b): string
{
return spl_object_id($a) === spl_object_id($b) ? 'same object' : 'two objects';
}
// 1. A facade with no application behind it yet.
try {
Cache::get('fee-total');
} catch (RuntimeException $e) {
show('Cache::get() before the app boots:', $e->getMessage());
}
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
// A class with no constructor arguments, so the container can build it without being told how.
class FeeCalculator {}
$app->bind('fees.bind', fn () => new FeeCalculator);
$app->singleton('fees.singleton', fn () => new FeeCalculator);
$app->scoped('fees.scoped', fn () => new FeeCalculator);
// 2-4. Ask for each binding twice.
show('make() of a class, no binding:', same($app->make(FeeCalculator::class), $app->make(FeeCalculator::class)));
show('bind(), asked twice:', same($app->make('fees.bind'), $app->make('fees.bind')));
show('singleton(), asked twice:', same($app->make('fees.singleton'), $app->make('fees.singleton')));
// 5-6. A new lifecycle: what a queue worker does before each job, and Octane before each request.
$scoped = $app->make('fees.scoped');
$single = $app->make('fees.singleton');
show('scoped(), same lifecycle:', same($scoped, $app->make('fees.scoped')));
$app->forgetScopedInstances();
show('scoped(), next lifecycle:', same($scoped, $app->make('fees.scoped')));
show('singleton(), next lifecycle:', same($single, $app->make('fees.singleton')));
// 7. A facade is a static-looking front for one object in the container.
show('Cache facade vs app(\'cache\'):', same(Cache::getFacadeRoot(), $app->make('cache')));
show('the object behind the facade:', get_class(Cache::getFacadeRoot())); from session import run
# Asks the service container for the same thing twice under each kind of binding, and calls a facade before
# and after the application boots. The overlay carries the probe; nothing secret is printed.
run(
"""
php container.php
""",
app=True,
overlay=("predict",),
) Output
$ php container.php
Cache::get() before the app boots: A facade root has not been set.
make() of a class, no binding: two objects
bind(), asked twice: two objects
singleton(), asked twice: same object
scoped(), same lifecycle: same object
scoped(), next lifecycle: two objects
singleton(), next lifecycle: same object
Cache facade vs app('cache'): same object
the object behind the facade: Illuminate\Cache\CacheManager
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 container_session.py
Read the lines in pairs. bind(), and a class with no binding at all, built a new object on every make(). A singleton: A binding whose object is built the first time something asks for it and then shared until the process ends, registered with singleton() or the #[Singleton] attribute. In a queue worker every job of the worker shares it. More in “The service container and dependency injection” was built once and returned again, even after the new lifecycle. A scoped instance: An object the container shares within one request or one queued job and drops before the next one, registered with scoped() or the #[Scoped] attribute. Queue workers and Octane reset these between units of work. More in “The service container and dependency injection” binding was shared within one lifecycle, and forgetScopedInstances() dropped it, so the next lifecycle got a new object. The queue worker calls that method before every job, and the container documentation says scoped instances are flushed when an Octane worker starts a new request.
| Binding | Twice in one lifecycle | After a new lifecycle |
|---|---|---|
bind() |
two objects | two objects |
singleton() |
same object | same object |
scoped() |
same object | a new object |
The difference shows in an interview the moment the process lives longer than a request. Under PHP-FPM: PHP's FastCGI Process Manager: a service that keeps a pool of PHP processes running, takes each request from the web server over FastCGI, runs the script and returns the response. More in “Deploying Laravel to a Linux server” each request boots a new application, so a singleton lasts one request and the bug stays hidden. A queue worker runs job after job in one booted application, so a singleton that keeps the current user, centre or locale in a property hands it to the next job. Laravel Octane: A first-party package that serves a Laravel application from a long-running server such as FrankenPHP, Swoole or RoadRunner. The application boots once and stays in memory between requests, so state kept in a singleton carries over to the next request. also keeps the booted application in memory between requests, so a singleton can carry such state into a later request there as well.
Keeping the request inside a singleton
The Octane documentation warns against passing the request or the container to the constructor of an object bound as a singleton: the object keeps the first request it saw. Register per-request state with scoped(), or pass the request to the method that needs it.
The last two lines are the facade answer’s proof: Cache::getFacadeRoot() and app('cache') were one CacheManager, which is what a facade: A class that names one entry of Laravel's container and forwards every static call on it to the object stored there, as Cache::get() forwards to the cache manager. More in “Service providers, facades and contracts” is, a static-looking front for one object in the container. The first line was its failure mode, the facade called before any application existed.
Checkpoint
Check: Bindings, lifecycles and facades in the container
Middleware wrap the controller: in, then out in reverse
A route lists two middleware: A class that sits between the web server and the controller. It can inspect or change a request, stop it with its own response, or pass it inward and then change the response on the way out. More in “How Laravel handles a request”, Outer and then Inner. Each prints one line before it calls $next($request) and another after it, and Outer also has a terminate() method. The probe sends two requests through the real HTTP kernel: The class that runs a web request from start to finish: it boots the framework, sends the request through the middleware and the router, and returns a response. More in “How Laravel handles a request”, and the second asks for a closed report, which Inner refuses. Predict both sets of lines:
predict/app/Http/Middleware/Outer.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
// The first route middleware of the probe. It prints a line before it passes the request on, a line
// when the response comes back, and a third from terminate(), which runs after the response is sent.
class Outer
{
public function handle(Request $request, Closure $next): Response
{
echo "Outer: on the way in\n";
$response = $next($request);
echo "Outer: on the way out, status {$response->getStatusCode()}\n";
return $response;
}
public function terminate(Request $request, Response $response): void
{
echo "Outer: terminate(), after the response\n";
}
} predict/app/Http/Middleware/Inner.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
// The second route middleware of the probe. When the request asks for a closed report it answers
// 403 itself and never calls $next, so the controller does not run.
class Inner
{
public function handle(Request $request, Closure $next): Response
{
if ($request->boolean('closed')) {
echo "Inner: answers 403 itself, never calls \$next\n";
return response('this report is closed', 403);
}
echo "Inner: on the way in\n";
$response = $next($request);
echo "Inner: on the way out\n";
return $response;
}
} routes/web.php · predict/routes/web.php
<?php
use App\Http\Middleware\Inner;
use App\Http\Middleware\Outer;
use Illuminate\Support\Facades\Route;
// Two routes for the predict-the-output probes. /report runs two route middleware in the order they
// are listed; /notices is a plain POST route inside the default web group, so the request-forgery
// check runs on it exactly as it would on a form.
Route::get('/report', function () {
echo "Controller: builds the response\n";
return response('the fee report');
})->middleware([Outer::class, Inner::class]);
Route::post('/notices', fn () => response('a notice was posted')); predict/middleware.php
<?php
// Predict the output: two requests through the real HTTP kernel to a route with two middleware,
// Outer and Inner, listed in that order. The second request asks for a closed report, which Inner
// refuses. terminate() is called the way public/index.php calls it, after the response. Run: php middleware.php
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Kernel::class);
foreach (['/report', '/report?closed=1'] as $uri) {
echo "--- GET {$uri}\n";
$request = Request::create($uri, 'GET');
$response = $kernel->handle($request);
echo "Kernel: handle() returned {$response->getStatusCode()}\n";
$kernel->terminate($request, $response);
} from session import run
# Sends two requests through the real HTTP kernel to a route with two middleware and prints the order in
# which each layer and the controller ran. The overlay carries the probe, the two middleware and the routes.
run(
"""
php middleware.php
""",
app=True,
overlay=("predict",),
) Output
$ php middleware.php --- GET /report Outer: on the way in Inner: on the way in Controller: builds the response Inner: on the way out Outer: on the way out, status 200 Kernel: handle() returned 200 Outer: terminate(), after the response --- GET /report?closed=1 Outer: on the way in Inner: answers 403 itself, never calls $next Outer: on the way out, status 403 Kernel: handle() returned 403 Outer: terminate(), after the response
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 middleware_session.py
The code before $next ran in the order the route lists, and the code after it ran in reverse, because each middleware wraps the ones listed after it. For the closed report, Inner returned its own 403 without calling $next: the controller never ran and Inner had no way out to print, yet Outer still printed the 403 on its way out. Step through both requests:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Outer runs first. The kernel passes GET /report to the first middleware the route lists. Outer prints its line for the way in, then calls $next($request).
- Inner runs second. Inner prints its own line and calls $next in turn. Only when the last middleware has called $next does the router run the controller.
- The controller answers. The controller builds the response, a 200 with the fee report, and returns it to the layer that called it.
- Back out through Inner. Inner's code after $next runs now, with the response in hand. On the way out the layers run in reverse order.
- Outer runs last on the way out. Outer prints its line for the way out and could still change the response, for example add a header. The kernel's handle() returns 200.
- terminate() after the response. After the response is sent, the kernel calls terminate() on every middleware that has one. Outer's third line came after "handle() returned 200".
- A layer can answer itself. For ?closed=1, Inner returns a 403 without calling $next, so the controller never runs. Outer still sees the 403 on its way out, because it wraps Inner.
terminate() ran last, after handle() had returned, because the kernel calls it once the response has been sent; the middleware documentation adds that with FastCGI the browser already has the response by then. The probe is the request lifecycle: The path one request takes through an application: the front controller, the kernel, the middleware, the router and the controller, then the response and the work that runs after it. More in “How Laravel handles a request” in miniature. public/index.php loads the application from bootstrap/app.php, and the kernel bootstraps it: configuration, error handling and logging, then every service provider’s register() and, once all are registered, each one’s boot(). Global middleware run next, the router picks the route, and its group and route middleware run around the controller. The response travels back out through the same layers before it is sent and terminated.
Checkpoint
Check: The order middleware run in
Counting queries: 11 for the lazy loop, 2 with eager loading
Here is the opening’s question, run. The probe makes ten batches with one teacher each, sends the same loop four ways and then switches lazy loading off. The query log: A record of every SQL statement a connection ran during a request, with its text, bindings and time. Laravel's DB::enableQueryLog() collects it, DB::getQueryLog() returns it, DB::listen() watches each one. More in “Databases and the query builder” records every statement, so its length is the count. Predict each number before you read it:
<?php
// Predict the output: how many SQL statements each version of one loop sends for ten batches and
// their teachers. The query log records every statement Eloquent sends, so its length is the count.
// Run: php queries.php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\LazyLoadingViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
class Teacher extends Model
{
public $timestamps = false;
}
class Batch extends Model
{
public $timestamps = false;
public function teacher()
{
return $this->belongsTo(Teacher::class);
}
}
Schema::create('teachers', function ($table) {
$table->id();
$table->string('name');
});
Schema::create('batches', function ($table) {
$table->id();
$table->string('name');
$table->foreignId('teacher_id');
});
foreach (range(1, 10) as $i) {
DB::table('teachers')->insert(['name' => "Teacher {$i}"]);
DB::table('batches')->insert(['name' => "Batch {$i}", 'teacher_id' => $i]);
}
// Run some work and return how many statements it sent.
function queries(callable $work): int
{
DB::flushQueryLog();
DB::enableQueryLog();
$work();
DB::disableQueryLog();
return count(DB::getQueryLog());
}
function show(string $case, string $result): void
{
printf("%-44s %s\n", $case, $result);
}
$names = fn ($batches) => $batches->map(fn ($batch) => $batch->teacher->name)->all();
show('1. Batch::all(), then each ->teacher', queries(fn () => $names(Batch::all())).' queries');
show('2. Batch::with(\'teacher\')->get()', queries(fn () => $names(Batch::with('teacher')->get())).' queries');
show('3. Batch::all()->load(\'teacher\')', queries(fn () => $names(Batch::all()->load('teacher'))).' queries');
Model::automaticallyEagerLoadRelationships();
show('4. Batch::all(), automatic eager loading on', queries(fn () => $names(Batch::all())).' queries');
Model::automaticallyEagerLoadRelationships(false);
// What AppServiceProvider::boot() does with Model::preventLazyLoading(! app()->isProduction()).
Model::preventLazyLoading();
try {
$names(Batch::all());
show('5. lazy loading prevented, ten batches', 'no exception');
} catch (LazyLoadingViolationException $e) {
show('5. lazy loading prevented, ten batches', class_basename($e));
echo ' '.$e->getMessage()."\n";
}
show('6. lazy loading prevented, Batch::find(1)', queries(fn () => Batch::find(1)->teacher->name).' queries, no exception'); from session import run
# Counts the SQL statements four versions of one loop send for ten batches and their teachers, then turns
# lazy loading off. The overlay carries the probe; it makes its own two tables in the project's SQLite file.
run(
"""
php queries.php
""",
app=True,
overlay=("predict",),
) Output
$ php queries.php
1. Batch::all(), then each ->teacher 11 queries
2. Batch::with('teacher')->get() 2 queries
3. Batch::all()->load('teacher') 2 queries
4. Batch::all(), automatic eager loading on 2 queries
5. lazy loading prevented, ten batches LazyLoadingViolationException
Attempted to lazy load [teacher] on model [Batch] but lazy loading is disabled.
6. lazy loading prevented, Batch::find(1) 2 queries, no exception
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 queries_session.py
The first loop is the N+1 query problem: A page that sends one query for a list and then one more for every row of it, usually by reading a relationship inside a loop, so its query count is 1 + N and grows with the data. More in “Eager loading and the N+1 query problem”: one query for the batches and one for each batch’s teacher, 1 + 10 = 11, a count that grows with the data. eager loading: Loading a relationship for a whole set of models at once, before the code reads it: with('student') sends one extra query with an IN list of keys and matches the rows in memory. More in “Eager loading and the N+1 query problem” with with('teacher') sent 2, the batches and then one query for every teacher whose id is in the list. load('teacher') did the same after the batches were already in memory, and automatic eager loading: Laravel's mode, switched on with Model::automaticallyEagerLoadRelationships(), that loads a relationship for a whole collection the first time one of its models reads it lazily. More in “Eager loading and the N+1 query problem” reached the same 2 without any change to the loop.
Line 5 is the guard. With Model::preventLazyLoading() on, the lazy loop threw LazyLoadingViolationException at its first lazy read. Line 6 is the detail that lifts an answer above the definition: Batch::find(1)->teacher loaded lazily and threw nothing. In Laravel 13.34.0, Eloquent marks models for the check only when a query returns more than one of them, because a single lazy load cannot multiply.
Turning the guard on everywhere, or trusting it alone
The documentation’s own example passes ! $this->app->isProduction(), so development and tests throw while production keeps serving a page that slipped through. The guard also misses the single model of line 6, and a query in a loop that does not go through a relationship at all, such as Teacher::find($id) for each row. Count the queries too.
Checkpoint
Check: Query counts, eager loading and the lazy-loading guard
Transactions and queued jobs: what survives a rollback
A fee payment is saved inside DB::transaction(), and a job that sends the receipt is dispatched in the same closure. Then the gateway times out and the closure throws. The probe runs this three ways on the sync queue, which runs a job the moment it is dispatched, the earliest a real worker could take it:
<?php
// Predict the output: a fee payment saved inside a database transaction that fails, and a receipt
// job dispatched inside it, first as a plain dispatch and then with afterCommit(). The queue is the
// sync driver here: it runs a job the moment it is dispatched, the earliest a real worker could pick
// it up. Run: php transactions.php
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
config(['queue.default' => 'sync']);
class SendReceipt implements ShouldQueue
{
use Dispatchable, Queueable;
public static int $sent = 0;
public function __construct(public int $paymentId) {}
public function handle(): void
{
self::$sent++;
$row = DB::table('payments')->where('id', $this->paymentId)->exists() ? 'exists' : 'is missing';
echo " job ran: receipt for payment {$this->paymentId}, the payment row {$row}\n";
}
}
Schema::create('payments', function ($table) {
$table->id();
$table->unsignedInteger('paise');
});
// Run one payment attempt; $fail makes it throw after the insert and the dispatch, as a gateway timeout would.
function attempt(string $case, bool $afterCommit, bool $fail): void
{
echo "{$case}\n";
SendReceipt::$sent = 0;
try {
DB::transaction(function () use ($afterCommit, $fail) {
$id = DB::table('payments')->insertGetId(['paise' => 150000]);
$afterCommit ? SendReceipt::dispatch($id)->afterCommit() : SendReceipt::dispatch($id);
if ($fail) {
throw new RuntimeException('gateway timeout');
}
});
} catch (RuntimeException $e) {
echo " transaction rolled back: {$e->getMessage()}\n";
}
printf(" payments in the table: %d, receipts sent: %d\n", DB::table('payments')->count(), SendReceipt::$sent);
DB::table('payments')->delete();
}
attempt('1. dispatch() inside, then the transaction fails', afterCommit: false, fail: true);
attempt('2. dispatch()->afterCommit() inside, then it fails', afterCommit: true, fail: true);
attempt('3. dispatch()->afterCommit() inside, and it commits', afterCommit: true, fail: false); from session import run
# Saves a payment inside a transaction that fails and dispatches a receipt job inside it, with and without
# afterCommit(), on the sync queue. The overlay carries the probe; it makes its own table.
run(
"""
php transactions.php
""",
app=True,
overlay=("predict",),
) Output
$ php transactions.php 1. dispatch() inside, then the transaction fails job ran: receipt for payment 1, the payment row exists transaction rolled back: gateway timeout payments in the table: 0, receipts sent: 1 2. dispatch()->afterCommit() inside, then it fails transaction rolled back: gateway timeout payments in the table: 0, receipts sent: 0 3. dispatch()->afterCommit() inside, and it commits job ran: receipt for payment 1, the payment row exists payments in the table: 1, receipts sent: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 transactions_session.py
A database transaction: A group of database writes that succeed or fail together: either all of them are committed, or a rollback undoes every one. Work outside the database is not undone. More in “Events, listeners and model observers” undoes database writes, and only those. In case 1 the job ran inside the transaction, found the payment row and sent its receipt; then the rollback removed the row, so the guardian holds a receipt for a payment the table does not have. A Redis or SQS queue reaches the same state another way: the queue documentation warns that a worker may take the job before the transaction commits, so the job can also find no row at all.
afterCommit() changes when the job is queued, not what it does. In case 2 the rollback discarded the job and nothing was sent; in case 3 the job waited for the commit and ran with the row in place. The same after commit: Running a handler only once the surrounding database transaction has committed, so it never acts on writes that a rollback undoes. With no transaction open, the handler runs at once. More in “Events, listeners and model observers” behaviour exists as the after_commit option of a queue connection, and an Eloquent observer gets it by implementing ShouldHandleEventsAfterCommit.
Two more details complete the answer. DB::transaction() throws the closure’s exception again after the rollback, which is why the probe caught it outside, and its attempts argument retries the whole closure when a deadlock happens. A job can also run twice, after a timeout or a crashed worker, and afterCommit() does nothing about that; it needs a different fix, which one of the interview questions below takes up.
Checkpoint
Check: Transactions and jobs dispatched inside them
Security answers with the classes Laravel uses
Security questions punish vague answers most, because “Laravel handles it” names nothing. The probe below shows four defences on one ordinary value each. Predict every line, the two validation messages included:
<?php
// Predict the output: four of Laravel's defences, each shown on one ordinary value: the
// request-forgery check, Blade escaping, validation and mass-assignment protection. Nothing here
// leaves this project. Run: php security.php
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Database\Eloquent\MassAssignmentException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Kernel::class);
function show(string $case, string $result): void
{
printf("%-46s %s\n", $case, $result);
}
// 1-2. Request forgery: a POST to a web route, without a token, then with the header a browser sets.
$plain = $kernel->handle(Request::create('/notices', 'POST'));
show('1. POST /notices, no token', (string) $plain->getStatusCode());
$sameOrigin = $kernel->handle(Request::create('/notices', 'POST', server: ['HTTP_SEC_FETCH_SITE' => 'same-origin']));
show('2. POST /notices, Sec-Fetch-Site: same-origin', (string) $sameOrigin->getStatusCode());
// 3-4. Output: the same stored name printed with {{ }} and with {!! !!}.
$name = '<b>Asha</b>';
show('3. Blade {{ $name }}', Blade::render('{{ $name }}', ['name' => $name]));
show('4. Blade {!! $name !!}', Blade::render('{!! $name !!}', ['name' => $name]));
// 5-6. Validation: the messages, and what validated() keeps.
$input = ['email' => 'asha@', 'phone' => '98765'];
$validator = Validator::make($input, ['email' => 'required|email', 'phone' => 'required|digits:10']);
foreach ($validator->errors()->all() as $message) {
show('5. validation message', $message);
}
$valid = Validator::make(['name' => 'Asha', 'is_admin' => '1'], ['name' => 'required|string']);
show('6. validated() keeps', implode(', ', array_keys($valid->validated())));
// 7-8. Mass assignment: a model that allows two fields, given a third.
class Guardian extends Model
{
protected $fillable = ['name', 'phone'];
}
$guardian = new Guardian(['name' => 'Asha', 'is_admin' => 1]);
show('7. new Guardian([...]) keeps', implode(', ', array_keys($guardian->getAttributes())));
Model::preventSilentlyDiscardingAttributes();
try {
new Guardian(['name' => 'Asha', 'is_admin' => 1]);
} catch (MassAssignmentException $e) {
show('8. with discarding prevented', class_basename($e));
echo ' '.$e->getMessage()."\n";
} from session import run
# Shows four defaults on one ordinary value each: the request-forgery check, Blade escaping, validation and
# mass-assignment protection. The overlay carries the probe and its routes; tokens and cookies are never printed.
run(
"""
php security.php
""",
app=True,
overlay=("predict",),
) Output
$ php security.php
1. POST /notices, no token 419
2. POST /notices, Sec-Fetch-Site: same-origin 200
3. Blade {{ $name }} <b>Asha</b>
4. Blade {!! $name !!} <b>Asha</b>
5. validation message The email field must be a valid email address.
5. validation message The phone field must be 10 digits.
6. validated() keeps name
7. new Guardian([...]) keeps name
8. with discarding prevented MassAssignmentException
Add fillable property [is_admin] to allow mass assignment on [Guardian].
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 security_session.py
Lines 1 and 2 are the request-forgery check: a POST with no proof got 419, and one carrying the browser-set Sec-Fetch-Site: A request header the browser sets itself, saying whether a request came from the same origin, the same site or another site. A page cannot forge it, so Laravel 13 accepts same-origin as proof that a form was posted from its own pages. More in “Laravel's security model and hardening” header with same-origin got 200. Lines 3 and 4 are output escaping: Converting characters such as < and > into HTML entities before printing a value, so untrusted text shows as text; Blade's {{ }} does this with htmlspecialchars to prevent cross-site scripting. More in “Your first page: route, controller and view”: {{ $name }} printed the tags as entities, and {!! $name !!} printed them raw. Line 6 shows validated data: The array a validator hands back: only the fields that had rules, holding the values the request carried, with no key the rules did not declare. More in “Validation rules and error messages” keeping only name, and lines 7 and 8 show mass assignment: Creating or updating a model from a whole array of fields in one call, which is why a model declares the columns such an array is allowed to write. More in “Your first CRUD feature with Eloquent” protection dropping is_admin silently and then, with discarding prevented, throwing. Each defence becomes one row of an answer:
| Question | Laravel’s mechanism | How it fails |
|---|---|---|
| Request forgery | PreventRequestForgery on the web group |
a route left out of the check, or a GET that changes data |
| Cross-site scripting | Blade’s {{ }} and htmlspecialchars |
{!! !!} on text a visitor wrote |
| Mass assignment | $fillable, and validated() before create() |
$guarded = [] with $request->all() |
| IDOR | a policy, or a query scoped to the owner | Note::find($id) straight from the URL |
The fourth row has no line in this probe, because an insecure direct object reference (IDOR): Access to a record through a real identifier, such as an id in a URL, that the application loaded but failed to authorize for the current user. More in “Authorization: gates and policies” is a missing check rather than a default you can print. The vulnerabilities lab runs it: the scoped query returned 404 for another centre’s note, and the unscoped one returned the note. An answer that names the policy or the scoped query, shows that 404, and says the check belongs on every route that takes an id is the complete one.
Checkpoint
Check: Security answers with Laravel's mechanisms
Practise: a week of spoken answers
Give each of these one sitting of about thirty minutes in your own copy of Pathshala, a day or two apart.
- Answer each section’s question aloud in the five parts, with a two-minute timer.
- Change one line of a probe, for example
singleton()toscoped()or the order of the two middleware, write down the output you expect, then run it. - Write down how many queries one page of your own app sends, then count them with
DB::enableQueryLog(). - Score each answer with the table below and spend the next session on the weakest level you did not reach.
| Level | What the answer covers | From this lesson |
|---|---|---|
| Fresher | the definition and one example | bind() builds a new object each time |
| 2 to 5 years | the mechanism by name, and a run | the 11 and the 2 from the query log |
| 5 to 10 years | the trade-off and the edge case | the single model the guard skips |
| 10 years and more | when not to use it, and how to roll it out | singletons that leak under Octane |
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): what is the difference between bind() and singleton() in Laravel’s service container?
bind() registers a recipe that the container follows on every make(), so each call gets a new object; singleton() builds the object the first time and returns that same object for as long as the application lives. The lesson’s probe printed “two objects” for bind() asked twice and “same object” for singleton(). The difference matters most in long-lived processes: a queue worker serves many jobs from one booted application, so a singleton that keeps per-job data hands it to the next job, and under Octane, which keeps the application in memory between requests, a singleton can carry per-request data into a later request. For state that belongs to one request or one job, scoped() shares the object within that lifecycle and drops it before the next.
Warm-up (fresher to 2-5 years): in what order do two route middleware run around the controller, and when does terminate() run?
In the order the route lists them on the way in, and in reverse on the way out, because each middleware wraps the ones after it. For a route with Outer and then Inner, the probe printed Outer in, Inner in, the controller, Inner out, Outer out. A middleware that returns its own response without calling $next stops the request there: the controller never runs, but every layer outside it still sees that response on its way out. terminate() runs after the response has been sent, so it suits work the visitor should not wait for, such as a log line.
Two longer questions grow out of this lesson’s probes, one on a page’s query count and one on a receipt job that runs twice. Sketch your first two moves for each before you open it, and check them against the opening of the answer:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Pathshala's batch report shows 25 batches a page, each with its teacher and its number of enrolled students, and it sends 52 queries a page. Bring it to a fixed number, keep it there with a test, and say what changes when an export must cover 5,000 batches.
-
Senior to staff
Pathshala's receipt job waits on a slow mail server, the queue retries it, and a guardian receives two receipts for one payment. Explain the ways a Laravel job can run more than once, make the receipt safe to repeat, and say how a test proves it.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which five parts make a strong answer to a Laravel interview question?
A one-sentence definition, the mechanism (the class or method that does it, by name), one trade-off, a failure mode and how you would see it, and the proof: a run or a test whose output shows the claim.
-
Why is "a static class you call with ::" a weak answer to "what is a facade"?
It describes the syntax, not the thing, and the follow-up about faking it in a test exposes it. A facade is a static-looking front for one object in the container: Cache::getFacadeRoot() and app('cache') were the same CacheManager.
-
What did the container probe return for bind(), singleton() and scoped(), asked twice and after a new lifecycle?
bind(): two objects, both times. singleton(): the same object, even after the new lifecycle. scoped(): the same object within a lifecycle and a new one after forgetScopedInstances(), which a queue worker calls before every job.
-
In which order did two route middleware, Outer then Inner, print their before and after lines?
The before code ran in the listed order and the after code in reverse, because each wraps the ones after it. When Inner refused with its own 403, the controller never ran, but Outer still printed on its way out; terminate() ran last, after the response was sent.
-
How many queries did a loop over ten batches printing each teacher send, lazily and with with('teacher')?
11 lazily, 1 + 10, a count that grows with the data; 2 with with('teacher'), and the same 2 with load() or automatic eager loading. With preventLazyLoading() on, the lazy loop threw at its first lazy read.
-
Which lazy queries does preventLazyLoading() not catch?
A single model fetched alone, such as Batch::find(1)->teacher, because Eloquent marks models for the check only when a query returns more than one; and a query in a loop that does not go through a relationship, such as Teacher::find($id) per row. Count the queries too.
-
A receipt job dispatched inside DB::transaction() ran before the closure threw. What went wrong, and what fixes it?
The job found the payment row and sent the receipt, then the rollback removed the row: a receipt for a payment that does not exist. afterCommit() queues the job only after the commit and discards it on rollback; it does nothing about a job that runs twice.
-
Which Laravel mechanism answers each of request forgery, XSS, mass assignment and IDOR?
PreventRequestForgery on the web group; Blade's double-brace echo through htmlspecialchars; $fillable with validated() before create(); a policy or a query scoped to the owner. Each fails when skipped: a route outside the check, a raw echo of visitor text, $guarded = [] with all(), Note::find($id) from the URL.
Key takeaways
- A strong answer gives a definition, the mechanism by name, one trade-off and a failure mode, and backs them with a run whose output you can quote.
bind()builds a new object on everymake(),singleton()shares one for as long as the application lives, andscoped()shares one per request or job, which is what Octane and queue workers need.- Middleware run in their listed order on the way in and in reverse on the way out; one that answers itself stops the request, and
terminate()runs after the response is sent. - A relationship read in a loop costs 1 + N queries, 11 for ten batches, while eager loading costs 2;
preventLazyLoading()catches the loop but not a single model. - A rollback undoes database writes only, so a job dispatched inside a transaction can act on a row that disappears;
afterCommit()queues it only after the commit. - Security answers name the mechanism: the request-forgery middleware, Blade’s escaping,
$fillablewithvalidated(), and a policy or an owner-scoped query against IDOR.
Terms in this lesson
- Laravel Octane
- A first-party package that serves a Laravel application from a long-running server such as FrankenPHP, Swoole or RoadRunner. The application boots once and stays in memory between requests, so state kept in a singleton carries over to the next request.
References
- Laravel 13.x: Request Lifecycle (Laravel (laravel.com))
- Laravel 13.x: Service Container (Laravel (laravel.com))
- Laravel 13.x: Facades (Laravel (laravel.com))
- Laravel 13.x: Middleware (Laravel (laravel.com))
- Laravel 13.x: Octane (Laravel (laravel.com))
- Laravel 13.x: Eloquent Relationships (Laravel (laravel.com))
- Laravel 13.x: Database, Getting Started (Laravel (laravel.com))
- Laravel 13.x: Queues (Laravel (laravel.com))
- Laravel 13.x: CSRF Protection (Laravel (laravel.com))
- Laravel 13.x: Eloquent, Getting Started (Laravel (laravel.com))
Report a problem with this lesson
Kept only in this browser. Your Learn progress