GAP·MAP

Laravel internals

[ middle depth ]

How Eloquent N+1 happens

Relations lazy-load by default: Eloquent queries a relationship only when you first touch the property. Read one inside a loop and you get a query per row.

$books = Book::all();            // 1 query
foreach ($books as $book) {
    echo $book->author->name;    // 1 query per book
}

For 25 books that is 1 + 25 = 26 queries. Eager loading collapses it:

$books = Book::with('author')->get();   // 2 queries total

Wrong "with() speeds this up by JOINing authors onto books." It does not JOIN. It runs a second, separate query, select * from authors where id in (1, 2, 3, ...), and matches authors to books in PHP. Right one query for the parents, one where in query for the relation, stitched in memory. Nested (with('author.contacts')) and multiple (with(['author', 'publisher'])) loads work the same way.

When you bind in the container

The container builds most classes for you. If a class has no dependencies, or depends only on other concrete classes, it resolves by reflection with no registration. Type-hint it in a controller, job, or listener and it is injected.

You bind when you map an interface to an implementation, or when construction needs customization. bind() runs its resolver on every resolution, so you get a fresh instance each time; singleton() resolves once and returns that same object thereafter.

Facades are container proxies

Cache::get('x') looks static but is not. The base facade's __callStatic() forwards the call to an object resolved from the container, and each facade's getFacadeAccessor() returns the binding key to resolve (Cache returns 'cache').

Because the call routes through a container instance, you can swap a mock in tests: Cache::shouldReceive('get')->andReturn('value'). A genuine static method could not be replaced this way.

Where middleware is registered now

Wrong "Register global middleware in app/Http/Kernel.php." Fresh Laravel 11 and later have no Kernel.php. Right middleware is configured in bootstrap/app.php via ->withMiddleware(...), using append, prepend, appendToGroup, and alias. The web group's CSRF check is PreventRequestForgery (renamed from the older VerifyCsrfToken); the api group ships only SubstituteBindings.

[ senior depth ]

How Octane leaks state across requests

php-runtime tears down all state after each request (shared-nothing). Octane does not: it boots the app once and feeds many requests to one in-memory Application instance. Providers' register() and boot() run once at worker startup. Octane resets its own framework state between requests but cannot reset your global and static state.

The classic corruption is a singleton that captures a request-scoped value in its constructor:

public function __construct(Request $request) {
    // resolved once, then frozen
    $this->locale = $request->header('Accept-Language');
}

Every later request reads the first one's header. Capturing $app (a stale container) or config in a long-lived binding fails the same way. Read through the request(), app(), or config() helpers instead, or inject a resolver closure (fn () => $app['request']), all of which stay current. A static $data[] = ... accumulator is the second leak, persisting across requests and growing without bound. scoped() bindings are flushed when a worker takes a new request, so they are the Octane-safe home for per-request state.

Bound stray leaks with --max-requests (default 500, then a graceful restart), and run octane:reload on deploy so in-memory code reflects the new build.

Facades versus dependency injection

Facades stay testable: Cache::shouldReceive(...) swaps a mock, so the argument against them is one of design clarity. A facade hides its dependency in the method body, so a class quietly accumulates coupling that a crowded constructor would have surfaced.

chunk, cursor, and lazy for large datasets

chunk(200, ...) pulls a page at a time and runs a query per page, so a whole table never loads at once.

Wrong "chunk() is safe when the loop updates the same column it filters on." chunk() pages by offset, so as updated rows stop matching the filter, the offset shifts and rows get skipped. Right use chunkById(), which pages by where id > lastId and stays stable under mutation.

cursor() uses one query and hydrates a single model at a time, but PDO still buffers all raw rows, so a huge result set can OOM anyway, and cursor cannot eager-load relations. lazy() streams in chunks and can eager-load, so it is the safer default for very large sets.

Queue and Horizon defaults that bite

The default attempt count is 1. A job that throws with no $tries set lands in failed_jobs on its first failure, so a job with no $tries or $backoff gets no retries. $timeout (default 60s) must stay below the connection's retry_after, or a worker can time out while the queue re-releases the job and it runs twice.

queue:work boots the framework once and holds it in memory, so deployed code is ignored until queue:restart. Horizon adds a Redis-only dashboard and code-driven autoscaling, but it requires Redis (not Redis Cluster) and still leans on Supervisor to keep the horizon process alive; on deploy you run horizon:terminate so it respawns with fresh code.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more PHP

was this useful?