How Laravel Works
The request lifecycle, MVC and what happens between index.php and a response.
Laravel is a PHP framework that turns an incoming HTTP request into an HTTP response. Everything else (routing, controllers, Eloquent, Blade, queues) hangs off that one job. Once you can picture the journey from public/index.php to the bytes sent back to the browser, the rest of the framework stops feeling like magic.
This lesson follows a single request through the framework, then looks at the architectural ideas that shape every Laravel app: MVC, the service container and convention over configuration.
The big picture
Every request to a Laravel site, whether it's the homepage, an admin screen or a JSON API call, goes through the same front controller: public/index.php. The web server is configured to send any URL that isn't a real file to that script. In production this site runs on a self-hosted IONOS VPS at demo1.matt-9.com, where Nginx terminates HTTPS (a Let's Encrypt certificate via Certbot) and passes PHP requests to the php8.4-fpm socket. Locally, the same setup runs in Docker Compose.
The rest of this lesson walks through each box.
Step by step: one request
Every request takes the same six steps. The table is the short version; each step has its own section below.
| Step | Where | What happens |
|---|---|---|
| 1. Front controller | public/index.php |
nginx hands every request to this one file |
| 2. Bootstrap | bootstrap/app.php |
The app, its routes, middleware and exception handling are configured |
| 3. Middleware | app/Http/Middleware, framework |
The request passes through layers: sessions, CSRF, auth, throttling |
| 4. Routing | routes/web.php, routes/api.php |
The URL and method are matched to a route |
| 5. Controller | app/Http/Controllers |
Your code runs: validate, query models, build a response |
| 6. Response | back through middleware | Headers and cookies are added on the way out, then it's sent |
1. The front controller
This is the real public/index.php from the project serving this page. It's only a few lines long:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Maintenance mode short-circuit (php artisan down)
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Composer's PSR-4 autoloader: no manual require_once anywhere else
require __DIR__.'/../vendor/autoload.php';
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
Three things happen. Composer's autoloader is registered, the application instance is created, and the incoming PHP superglobals ($_GET, $_POST, $_SERVER, cookies, files) are wrapped in an Illuminate\Http\Request object.
2. Bootstrapping the application
bootstrap/app.php builds the Application, which is also the service container. Since Laravel 11 this file is where routing, middleware and exception handling are configured fluently. This is this app's real file:
bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
// API routes always answer with JSON (422 errors, 404s), even without an Accept header
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson()
);
})->create();
It wires up three route files: browser pages, the Projects & Tasks demo API (automatically prefixed with /api), and Artisan commands. It also adds a free /up health-check endpoint, which a load balancer can poll. The exception rule means a failure under /api/* always comes back as JSON, never as an HTML error page.
When the kernel handles the request it runs a list of bootstrappers in order: load .env, load the config/*.php files, register the exception handler, register facades, then register and boot every service provider.
Register vs boot. Every provider's register() runs first, and it should only bind things into the container. Only once every provider is registered does Laravel call each boot() method. At that point any service can safely be resolved. That's why view composers, event listeners and route model bindings go in boot().
3. Middleware: the onion
Before your code runs, the request passes through middleware, which are layers that can inspect, modify or reject it. The global stack handles things like trimming strings, converting empty strings to null and validating the trusted proxy. The web group adds sessions, cookie encryption and CSRF protection.
Each middleware gets the request and a $next closure. Whatever it does before calling $next runs on the way in. Whatever it does after runs on the way out, with the response in hand. This app's demo API uses one to report how much work each request did:
app/Http/Middleware/AddQueryCountHeader.php
/**
* Exposes how many SQL queries a request ran and how long it took,
* so the tutorials can show the cost of each endpoint live.
*/
class AddQueryCountHeader
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$startedAt = microtime(true);
$queryCount = 0;
DB::listen(function () use (&$queryCount) {
$queryCount++;
});
$response = $next($request);
$response->headers->set('X-Query-Count', (string) $queryCount);
$response->headers->set('X-Response-Time', round((microtime(true) - $startedAt) * 1000, 1).'ms');
return $response;
}
}
Everything above $next($request) runs on the way in: start the timer and listen for queries. Everything below runs on the way out, after the controller has finished, which is when the headers are added. Open the browser dev tools on /api/demo/projects and you'll see X-Query-Count and X-Response-Time on the response, alongside X-RateLimit-Limit and X-RateLimit-Remaining from the throttle:demo middleware.
4. Routing
The router compares the request's method and path with the registered routes. It runs the first match, after applying any route-specific middleware (auth, throttle:demo and so on). These are the web routes of this site:
routes/web.php
Route::get('/', [PageController::class, 'home']);
Route::get('/blog', [BlogController::class, 'index']);
Route::get('/blog/type/{typeSlug}', [BlogController::class, 'byType'])->where('typeSlug', '[a-z0-9\-]+');
Route::get('/blog/{slug}', [BlogController::class, 'show'])->where('slug', '[a-z0-9\-]+');
Route::get('/tutorials', [TutorialController::class, 'index'])->name('tutorials.index');
Route::get('/tutorials/{track}', [TutorialController::class, 'track'])->where('track', '[a-z0-9\-]+')->name('tutorials.track');
Route::get('/tutorials/{track}/{slug}', [TutorialController::class, 'show'])->where(['track' => '[a-z0-9\-]+', 'slug' => '[a-z0-9\-]+'])->name('tutorials.show');
// Catch-all for CMS pages — keep last
Route::get('/{slug}', [PageController::class, 'show'])->where('slug', '[a-z0-9\-]+');
Order matters. The /{slug} catch-all would happily match tutorials, so it's registered last.
5. The controller
The matched controller method is called through the container, so its dependencies are injected automatically. The page you're reading was rendered by this method:
app/Http/Controllers/TutorialController.php
class TutorialController extends Controller
{
public function __construct(private TutorialRepository $tutorials) {}
// ...
public function show(string $track, string $slug): Response
{
$trackData = $this->tutorials->track($track) ?? abort(404);
$found = $this->tutorials->find($track, $slug) ?? abort(404);
return response(view('tutorials.show', [
'trackKey' => $track,
'track' => $trackData,
'levels' => $this->tutorials->levels(),
'page' => $found['page'],
'prev' => $found['prev'],
'next' => $found['next'],
'content' => $this->tutorials->render($track, $slug),
'demo' => $this->tutorials->demoScript($track, $slug),
]));
}
}
Nobody wrote new TutorialRepository(). The container saw the type hint and built one.
6. The response travels back
The controller returns a view, an array, a model or a Response. Laravel normalises it into a Symfony Response, which then passes back out through the middleware (adding cookies, headers and so on). Finally $response->send() writes the headers and body. After that the kernel's terminate() runs any "after response" work, such as terminable middleware or dispatch(...)->afterResponse().
Walk the lifecycle yourself
Click through the stages to see what each layer is responsible for, and what would stop a request there.
MVC in Laravel
Laravel is an MVC framework, but in practice it helps to think of more than three layers:
| Layer | Laravel piece | Responsibility |
|---|---|---|
| Model | Eloquent models (app/Models) |
Data, relationships, business rules close to the data |
| View | Blade templates (resources/views) |
Presentation only, no queries |
| Controller | app/Http/Controllers |
Glue: take a request, call the domain, return a response |
| Routing | routes/*.php |
Map URLs + verbs to controllers |
| Middleware | app/Http/Middleware |
Cross-cutting request concerns (auth, CSRF, locale) |
| Services | app/Services (convention) |
Business logic that doesn't belong to one model |
| Requests | Form Requests | Validation + authorisation of input |
The running example for the Laravel track is a small Projects & Tasks app that really runs inside this site. A Project has many Tasks, each Task belongs to a Project and is assigned to a User, and tasks have many Tags. It's seeded with 3 projects, 20 tasks, 5 tags and 4 demo users, and exposed as a JSON API that the live demos in these lessons call.
Here's one request, GET /api/demo/projects/website-relaunch, flowing through those layers.
routes/api.php (inside the demo group)
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
app/Http/Controllers/Api/ProjectController.php
public function show(Project $project): ProjectResource
{
$project->load(['owner', 'tasks' => fn ($query) => $query->orderBy('id'), 'tasks.assignee', 'tasks.tags'])
->loadCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()]);
return new ProjectResource($project);
}
Route model binding has already turned website-relaunch into a Project (the model's route key is its slug). The controller loads the relationships the response needs, the Model layer. Because this is an API, the View layer isn't Blade: it's an API resource that shapes the JSON.
app/Http/Resources/ProjectResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'owner' => $this->whenLoaded('owner', fn () => $this->owner->name),
'tasks_count' => $this->whenCounted('tasks'),
'open_tasks_count' => $this->whenCounted('open_tasks'),
'tasks' => TaskResource::collection($this->whenLoaded('tasks')),
'url' => route('api.demo.projects.show', $this->resource),
];
}
See it running: /api/demo/projects/website-relaunch.
Where does the logic go? "Thin controllers, fat models" is the classic advice, but large apps push logic into service or action classes instead, so models don't become god objects. A sensible split: controllers translate HTTP into method calls, validation lives in Form Requests, and reusable business logic lives in classes the container can inject and tests can swap.
The service container: Laravel's backbone
The Application object is an IoC (inversion of control) container. It knows how to build objects and their dependencies. It's what makes controllers, jobs, listeners, middleware and commands injectable. It has a whole lesson of its own next, but the core idea fits in four lines. Illustrative:
// Bind an interface to an implementation (usually in a service provider)
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// Anywhere a PaymentGateway is type-hinted, you get a StripeGateway
public function __construct(private PaymentGateway $payments) {}
This project registers its own services in AppServiceProvider. SiteConfigService is a singleton, built once per request and shared. boot() defines the demo API's rate limiter and shares the site config with every view:
app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(SiteConfigService::class);
}
public function boot(): void
{
// Public tutorial demo API: 120 requests a minute per IP
RateLimiter::for('demo', fn (Request $request) => Limit::perMinute(120)->by($request->ip()));
// Share site config with every view
View::composer('*', function (\Illuminate\View\View $view) {
$config = app(SiteConfigService::class)->get();
$view->with('siteConfig', $config);
});
}
Convention over configuration
Laravel removes boilerplate by agreeing on defaults:
- The
Taskmodel maps to thetaskstable, with primary keyidandcreated_at/updated_attimestamps. Nothing inapp/Models/Task.phpsays so. Task::project()isbelongsTo(Project::class), which looks for aproject_idcolumn.Task::assignee()breaks the naming convention (the column isuser_id), so it passes the key explicitly:belongsTo(User::class, 'user_id').view('tutorials.show')resolves toresources/views/tutorials/show.blade.php, the template of this page.- The many-to-many pivot between tags and tasks is called
tag_task: both singular names, in alphabetical order. php artisan make:model Task -mfcreates the model, migration and factory in the right folders.
You can override any of these (protected $table = 'todo_items';), but following them keeps the codebase predictable for the next developer.
What runs once vs every request
PHP-FPM uses a shared-nothing model. Every request boots the framework from scratch and throws it away afterwards. That's simple (no memory leaks between users), but it means boot cost matters. Laravel claws it back with caches:
php artisan config:cache # merge config/*.php into one cached file
php artisan route:cache # serialise the compiled route collection
php artisan view:cache # pre-compile every Blade template
php artisan optimize # all of the above (plus events)
This site's deploy script runs config:cache, route:cache and view:cache on the VPS after every release, and the local Docker entrypoint caches config and routes on container start. That's also why a new route doesn't appear until the cache is rebuilt, a classic deployment gotcha.
After config:cache, calls to env() outside the config/ files return null, because .env is no longer loaded. Always read settings through config('app.name') in application code.
Long-lived alternatives such as Laravel Octane (Swoole, RoadRunner, FrankenPHP) boot the app once and reuse it across requests. That's much faster, but singletons and static state now leak between requests unless you're careful.
Key takeaways
- Every request enters through
public/index.php, becomes aRequestobject and leaves as aResponse. - Bootstrapping loads env, config and providers. Providers register bindings first, then boot.
- Middleware wraps the app like an onion. It can act before and after the controller.
- The router matches method + path in order, so catch-all routes go last.
- Controllers are resolved through the service container, so their dependencies are injected.
- Conventions (table names, foreign keys, view paths) remove configuration. Caches remove boot cost in production.