Skip to main content
Blog

Laravel: The Clean Stack for Artisans and Agents

Last updated Architecture

Laravel has been the dominant PHP framework for over a decade, which in web framework terms is close to geological. That longevity is not an accident of marketing. It comes from a specific set of design choices — strong conventions, batteries genuinely included, and an obsessive focus on the developer's experience of reading and writing the code.

This guide covers how Laravel is structured, which conventions matter, where applications usually go wrong as they grow, and how to keep a codebase productive past the point where the initial simplicity stops carrying it. No code, but plenty of specifics.


What you will learn
  • The architecture and the request lifecycle
  • Which conventions are load-bearing and which are optional
  • Eloquent's strengths and the performance traps
  • Structuring an application that grows beyond the default layout
  • Queues, events, scheduling and the asynchronous layer
  • Testing, deployment and the ecosystem worth knowing
In this article
  1. What Laravel actually is
  2. The request lifecycle
  3. The service container
  4. Routing and controllers
  5. Middleware
  6. Validation and form requests
  7. Eloquent: the good parts
  8. Eloquent: the traps
  9. Migrations and the database layer
  10. Authorisation
  11. Queues and jobs
  12. Events and listeners
  13. Scheduling and commands
  14. Structuring a growing application
  15. APIs and resources
  16. The frontend question
  17. Testing
  18. Performance
  19. The ecosystem
  20. Twelve mistakes
  21. A worked example: a subscription billing feature
  22. Frequently asked questions

1. What Laravel actually is

Laravel is a full-stack framework built on top of a set of well-tested lower-level components, with an opinionated layer over them that makes the common case pleasant.

The distinguishing philosophy is that the framework should do the boring things for you, completely, without configuration. Authentication, validation, database access, queuing, scheduling, mail, file storage, caching, testing — all present, all working together, all following the same conventions. You do not assemble a stack; you start building.

This is a genuine trade. Frameworks that make fewer decisions give you more freedom and require more assembly. Laravel's bet is that for most applications, a good decision made for you beats a decision you have to make, and a decade of adoption suggests the bet was reasonable.

The corresponding cost is that fighting the conventions is expensive. An application that uses Laravel's structure gets enormous leverage; an application that layers a different architecture on top gets the framework's weight without much of its benefit.

2. The request lifecycle

Understanding what happens between a request arriving and a response leaving explains where to put things.

  1. Entry. Every request hits a single front controller.
  2. Bootstrap. The application container is created and service providers register their bindings.
  3. Global middleware. Runs for every request — session handling, cookie encryption, and anything else applying universally.
  4. Routing. The request is matched to a route.
  5. Route middleware. Runs for matched routes — authentication, rate limiting, and anything route-specific.
  6. Controller. The action executes.
  7. Response. Returned back out through the middleware stack, which can modify it on the way.
  8. Termination. Deferred work runs after the response has been sent.

Two things worth noting. Middleware wraps the request in both directions, so it can act before and after the controller. And the termination phase is where work that need not delay the response belongs — logging, analytics, cleanup — though for anything substantial a queued job is better.

3. The service container

The container is the piece that makes everything else composable, and understanding it separates people who use Laravel from people who understand it.

The container resolves dependencies automatically. When something needs a class, the container constructs it, resolving that class's own dependencies recursively. You rarely construct objects manually.

Service providers are where bindings are registered — telling the container that a particular interface should resolve to a particular implementation, or how to construct something that needs configuration.

The practical benefit is substitutability. Code that depends on an interface rather than a concrete class can have that implementation swapped — for a different provider, for a test double, for a decorated version. This is what makes Laravel applications testable without elaborate mocking.

Facades are the controversial part. They provide a static-looking interface to container-resolved services. They read beautifully and they obscure dependencies — a class using five facades has five dependencies its constructor does not mention. The pragmatic position most experienced teams reach: facades in controllers and simple code where readability wins, constructor injection in domain and service classes where explicit dependencies matter for testing and comprehension.

4. Routing and controllers

Routes map URLs to actions and live in dedicated files, separated by context — web routes with session state, API routes without.

Route model binding is the convention worth understanding early. A route parameter typed as a model is automatically resolved to that record, with a not-found response if it does not exist. This removes a fetch-and-check from nearly every controller action and is one of those small conveniences that compounds across a codebase.

For controllers, the guidance that ages best: keep them thin, and keep them focused. A controller's job is to accept a request, delegate, and return a response. When business logic accumulates in controllers, it becomes untestable except through HTTP and unreusable from a queue job or a command.

Resource controllers — the seven standard actions for a resource — are worth following where they fit, because the naming is predictable and every Laravel developer knows it. Where an operation is not one of the seven, a single-action controller with a descriptive name is better than bending it into an update.

5. Middleware

Middleware handles cross-cutting concerns: authentication, authorisation checks that apply broadly, rate limiting, logging, locale detection, forcing HTTPS.

The test for whether something belongs in middleware: does it apply to many routes and is it about the request rather than the domain? Authentication, yes. Checking whether this particular user can edit this particular record, no — that is authorisation logic belonging in a policy, where it can also be applied outside an HTTP context.

Middleware groups let you apply a set to many routes at once, and this is where most applications should manage their middleware rather than listing it per route.

6. Validation and form requests

Laravel's validation is one of its strongest features, and using it fully removes a large category of defensive code.

Validation rules are declarative and composable, covering the common cases — required fields, formats, uniqueness against a database, conditional requirements, and validation of nested arrays.

Form requests are the pattern worth adopting: a dedicated class holding the validation rules and the authorisation check for a specific request. The controller receives an already-validated request and can proceed. This keeps controllers clean, makes rules testable in isolation, and puts the rules somewhere findable.

Two practices that pay off. Validate at the boundary and trust afterwards — re-checking the same conditions in three layers is noise. And write custom rules for domain concepts rather than expressing them as a chain of primitives; a rule named for the business concept is self-documenting and reusable.

7. Eloquent: the good parts

Eloquent maps database tables to model classes, and its appeal is that the common operations read like the intent.

What it does genuinely well:

Relationships. Declaring that a user has many orders, that an order belongs to a customer, that a post has many tags through a pivot table — and then traversing those relationships naturally. The relationship definitions are concise and the query generation is handled.

Query scopes. Named, reusable query fragments attached to a model. A scope for "active" or "published this month" turns repeated conditions into vocabulary, and scopes compose.

Accessors, mutators and casts. Transforming attributes on read and write — a date column becoming a date object, a JSON column becoming an array, an encrypted column decrypting transparently. This keeps conversion logic in one place rather than scattered across the application.

Events. Hooks on model lifecycle — creating, created, updating, deleted. Useful for audit trails and cache invalidation, and easily overused, which the next section covers.

Soft deletes. Records marked deleted rather than removed, excluded from queries by default and recoverable. A few characters of configuration for something that would otherwise be a recurring source of bugs.

8. Eloquent: the traps

The convenience has costs, and they are performance costs that appear at scale rather than in development.

The N+1 query problem is the dominant one. Fetching a hundred orders and then accessing each order's customer produces one query for the orders and a hundred for the customers. In development with ten records this is invisible; in production with ten thousand it is the whole performance problem.

The fix is eager loading — telling the query to fetch the relationships up front. The discipline that actually prevents it: enable strict mode in development so that lazy loading throws an exception. This converts a silent performance problem into a loud error at the moment it is written, and it is the single most valuable configuration change available in a Laravel application.

Loading too much. Fetching whole models when three columns were needed, or fetching a large result set into memory when it should be processed in chunks. A query returning fifty thousand hydrated models will exhaust memory.

Model events causing cascades. An event on save that touches another model, which fires its own event, which touches a third. Easy to write, difficult to trace, and a common cause of mysterious slowness and unexpected writes.

Fat models. The natural place to put logic is the model, and models grow to thousands of lines containing business rules, formatting, notification triggers and query helpers. The framework does not prevent this; discipline does.

Mass assignment carelessness. Laravel protects against it, and the protection is routinely disabled because it is inconvenient. Disabling it means a request can set any column, including ones like an administrator flag.

9. Migrations and the database layer

Migrations define schema changes as versioned, ordered files. Every environment reaches the same schema by running the same sequence.

The practices that matter in a team:

Never edit a migration that has run anywhere but your machine. Write a new one. An edited migration means environments diverge silently, and diagnosing that is unpleasant.

Make migrations reversible where practical, and be honest where they are not. A migration that drops a column cannot restore the data, and pretending otherwise is worse than declaring it irreversible.

Separate schema changes from data changes. A migration that adds a column and backfills it works fine until the backfill takes an hour on production volumes and locks the table.

Watch for locking on large tables. Adding an index to a table with millions of rows can block writes for a long time. This is a deployment concern that development never surfaces.

Seeders and factories deserve investment. Factories that generate realistic model instances make testing far easier, and a well-built factory used across a test suite is one of the highest-leverage things in a Laravel codebase.

10. Authorisation

Policies are the mechanism: a class per model holding the rules for who may view, create, update or delete it. The framework resolves the right policy automatically based on the model.

The value of policies over inline checks is that authorisation lives in one findable place, applies consistently whether the check happens in a controller, a view or a queue job, and is testable directly.

Gates handle authorisation not tied to a model — whether a user may access an admin area, whether a feature is available to their plan.

The rule worth enforcing: authorisation happens in policies, and views only decide what to display. Hiding a button is presentation; preventing the action is authorisation. Applications that conflate the two have security holes that appear the moment someone calls the endpoint directly.

11. Queues and jobs

Anything slow, unreliable or non-essential to the response belongs on a queue. Sending mail, generating documents, calling external services, processing uploads, computing aggregates.

A job is a class with the work in it, dispatched to a queue and executed by a worker process. The framework handles serialisation, retries and failure recording.

The details that matter in production:

Jobs must be idempotent. They will be retried. A job that charges a card must not charge twice when it runs again after a timeout.

Configure retries and backoff deliberately. A job hammering a failing external service makes the outage worse. Exponential backoff and a sensible attempt limit.

Monitor the failed job table. Failures land there and are ignored in a remarkable number of applications. A queue with three thousand failed jobs is an application with three thousand things that did not happen.

Use multiple queues with priorities. A single queue means a slow batch job delays every password reset email behind it.

Deploy carefully. Workers hold code in memory and must be restarted after a deployment, or they continue running the previous version. Every Laravel team learns this once.

Batching and chaining handle the cases where jobs relate — a set that must all complete before a follow-up, or a sequence where each depends on the last.

12. Events and listeners

Events decouple: something happened, and interested parties respond without the originator knowing about them. An order was placed; separately, a confirmation is sent, inventory is adjusted and analytics are recorded.

Listeners can be queued, which is the usual choice for anything slow.

The honest assessment: events are frequently overused. They make control flow implicit, and a codebase where important logic is scattered across listeners triggered by model events is genuinely hard to follow — reading a controller tells you nothing about the six other things that happen.

A reasonable boundary: use events when there are genuinely multiple independent consumers, or when the consumers belong to different parts of the system. Call the code directly when there is one consumer and the relationship is essential rather than incidental. "Send the confirmation email" after placing an order is not decoupled by making it an event; it is merely hidden.

13. Scheduling and commands

Console commands are for anything run outside a request — maintenance, imports, reports, one-off operations. They benefit from the same container and services as the rest of the application, so business logic is reusable between an HTTP action and a command.

The scheduler defines recurring tasks in code rather than in system configuration, with a single entry invoking the framework's scheduler every minute.

Two features worth using. Overlap prevention stops a task starting again while the previous run is still going, which matters for anything whose duration can exceed its interval. And single-server execution ensures a scheduled task runs once across a multi-server deployment rather than once per server — the failure mode being duplicate emails to every customer.

14. Structuring a growing application

The default structure is excellent for small applications and insufficient beyond a certain size, and knowing when to move is a judgement worth making deliberately.

The progression most applications follow:

Small. The default layout. Controllers, models, a few form requests. Business logic in models is fine. Do not add architecture before there is a problem.

Medium. Controllers are getting fat and models fatter. Introduce action classes — a class per meaningful operation, with one public method. This gives business logic a home, makes it testable without HTTP, and makes it reusable from a command or a job. This single change addresses most of the pain at this stage.

Larger. Group by domain rather than by technical type. Instead of every controller in one directory and every model in another, a directory per business area containing its controllers, models, actions and tests. Related code sits together and boundaries become visible.

Large. Explicit modules with defined interfaces between them, potentially separate packages. Worth it when teams are stepping on each other; overhead otherwise.

The mistake worth avoiding is importing an elaborate architecture at the start. Layers of repositories and abstractions added before there is a concrete problem produce indirection without benefit, and they fight Eloquent, which was designed to be used directly.

15. APIs and resources

For JSON APIs, API resources are the right tool — a transformation layer between models and output, controlling exactly which fields are exposed and how they are shaped.

Returning models directly is convenient and dangerous. The response includes whatever columns the model has, which means adding a column to the database silently adds it to the API — including, eventually, one that should not have been exposed.

The other essentials for a serviceable API: consistent error shapes across every endpoint; pagination on any collection that can grow; rate limiting; token-based authentication with scoped abilities; and versioning decided before you need it rather than after.

16. The frontend question

Laravel supports several approaches, and the choice matters more than the framework's documentation implies.

ApproachCharacterSuits
Server-rendered templatesTraditional pages, minimal JavaScriptContent sites, admin panels, anything simple
Server-driven interactivityDynamic behaviour without writing JavaScriptTeams without frontend specialists; moderately interactive apps
Coupled single-page approachA JavaScript frontend with no separate APIRich interfaces with one client
Separate frontend plus APIFully decoupledMultiple clients; separate frontend teams

The observation worth acting on: most applications reach for a heavier option than they need. A well-built server-rendered application with targeted interactivity is faster to build, faster to load, simpler to deploy and easier to maintain than a decoupled architecture, and a large proportion of business applications never need more. Choose the separate-frontend path when you have a genuine reason — multiple clients, a distinct frontend team, an interface that is genuinely an application rather than a set of pages.

17. Testing

Laravel's testing story is one of its strongest features, and applications that use it fully are markedly more maintainable.

Feature tests exercise the application through HTTP: make a request, assert on the response, assert on the database. These give the most confidence per line of test code and are the right default for most functionality.

Unit tests for isolated logic — calculations, transformations, domain rules extracted into their own classes.

The supporting facilities matter as much as the test types. Factories generate realistic model data with relationships, and a good set of factories makes writing a test a two-line affair rather than a twenty-line setup. Database refresh between tests gives isolation. Fakes for mail, queues, notifications, storage and HTTP let you assert that something would have been sent without sending it, which covers a large fraction of what applications do.

The practice worth establishing early: every bug fix gets a test that fails before the fix. It is the cheapest way to build a suite that reflects what actually breaks, and over a couple of years it produces coverage that no coverage target would have.

18. Performance

Roughly in order of typical impact:

  • Fix N+1 queries. Almost always the largest single win. Enable strict mode so they cannot be written unnoticed.
  • Add database indexes for the columns actually filtered and joined on. Frequently missing on foreign keys added by hand.
  • Cache expensive computations and rarely-changing queries, with deliberate invalidation.
  • Move slow work to queues. Anything the user does not need to wait for.
  • Cache configuration, routes and views in production. A one-line deployment step with a real effect.
  • Paginate everything. A collection with no limit will eventually have too many rows.
  • Chunk large operations rather than loading everything into memory.
  • Consider a persistent application server for high-traffic applications, which keeps the framework booted between requests and changes the performance profile substantially — with the caveat that it exposes any state accidentally retained between requests.

Measure before optimising. Laravel has good tooling for seeing which queries ran and how long each part of a request took, and the answer is usually a query rather than the thing you suspected.

19. The ecosystem

Part of Laravel's value is that the surrounding tools follow the same conventions.

There are first-party packages for authentication scaffolding, API tokens, social login, subscription billing, full-text search, queue monitoring, administrative interfaces, real-time broadcasting and local development environments — plus hosting and deployment tooling built specifically for Laravel applications.

The practical benefit is not that these exist but that they are consistent with each other and with the framework. Adding subscription billing does not mean adopting a different architecture; it means installing something that works the way the rest of your application works.

The corresponding caution: each package is a dependency with its own upgrade path, and a project using twelve of them has twelve things to keep current. Add what you will genuinely use.

20. Twelve mistakes

  1. Not enabling strict mode. N+1 queries stay invisible until production.
  2. Fat controllers. Logic that cannot be tested or reused outside HTTP.
  3. Fat models. Thousands of lines of unrelated concerns in one class.
  4. Business logic in model events. Implicit cascades nobody can trace.
  5. Disabling mass assignment protection because it was inconvenient.
  6. Returning models directly from APIs. New columns become new public fields.
  7. Editing migrations that have already run. Silent environment divergence.
  8. Non-idempotent jobs. Retries cause duplicate side effects.
  9. Ignoring the failed jobs table. Things that silently did not happen.
  10. Forgetting to restart workers on deployment. Old code running indefinitely.
  11. Authorisation only in views. Hiding a button is not preventing an action.
  12. Adding architecture before there is a problem. Indirection without benefit.

21. A worked example: a subscription billing feature

Adding paid plans to an existing application — plan selection, payment, upgrades and downgrades, failed payment handling, and access control based on the active plan.

Structure. The team is at the medium stage — controllers getting fat — so this feature is built with action classes from the start. One action per operation: subscribe, change plan, cancel, resume, handle a failed payment. Each has a single public method, takes explicit dependencies, and knows nothing about HTTP. The controllers become four lines each.

Validation. Form requests per endpoint, with a custom rule for plan eligibility, because "can this account move to this plan" involves the current plan, outstanding balance and account age. Expressing that as a named rule rather than a chain of conditions makes it reusable in the API and readable in the request class.

Data model. Migrations add subscriptions and plan tables and a foreign key on accounts, each with explicit indexes. Schema and backfill are separate migrations, because the backfill assigns every existing account to a free plan and touches four hundred thousand rows — which in a combined migration would have locked the table during deployment.

External payment provider. Wrapped behind an interface, bound in a service provider. The real implementation calls the provider; a fake implementation is bound in tests. This single decision is why the test suite for this feature runs in eight seconds and never touches the network.

Queues. Payment attempts, invoice generation and dunning emails are all queued. Every job is idempotent — the payment job checks whether a charge already exists for the period before creating one, which matters because the first production incident was a timeout that caused a retry. Jobs go on a dedicated billing queue with a higher priority than the reporting queue, so a slow monthly report never delays a payment retry.

Events, used sparingly. A subscription-changed event exists because three genuinely independent things respond to it: analytics, the search index and a customer support notification. Sending the confirmation email is not an event — it is a direct call inside the action, because there is one consumer and hiding it behind an event would only have made the flow harder to read.

Authorisation. A policy on the subscription model covering who may view and modify it, and a gate for plan-gated features. The views check the gate to decide what to show; the actions check the policy to decide what to permit. Both, deliberately — the view check is presentation and the action check is security.

Scheduling. A daily command processes renewals, with overlap prevention because the run can exceed an hour at volume, and single-server execution because the deployment runs three application servers. The second of those was added after a staging run sent every renewal notice three times.

Testing. Feature tests cover each endpoint end to end with the payment provider faked, asserting on responses and on database state. Unit tests cover the proration calculation, which is pure arithmetic with a dozen edge cases and is far easier to test directly than through HTTP. Factories generate accounts on each plan state, which turns most test setup into one line.

What went wrong. Strict mode had not been enabled, and the account listing page ran one query per account to fetch its plan — invisible with the developer's forty test accounts and a four-second page load with four hundred thousand. Enabling strict mode surfaced eleven more instances across the application within a day. That configuration change did more for performance than any subsequent optimisation.

What made it work. Action classes gave the logic a home outside controllers, which made it reusable from the scheduled command without duplication. The payment provider behind an interface made the whole feature testable. And separating schema from data migrations kept a deployment from locking the accounts table during business hours.

22. Frequently asked questions

Is Laravel suitable for large applications?

Yes, with structural discipline the default layout does not impose. Move business logic into action classes as controllers grow, group by domain rather than by technical type once there are several areas, and be deliberate about where logic lives. The framework scales; the default directory structure is what needs to evolve.

Should I use repositories over Eloquent?

Usually not. Eloquent is designed to be used directly, and a repository layer over it typically adds indirection while forfeiting the query builder's expressiveness. The genuine cases are where you need to swap the persistence mechanism or where a domain model deliberately differs from the database schema — both real, both rarer than the pattern's popularity suggests.

What is the single most valuable configuration change?

Enabling strict mode in development, so lazy loading throws an exception. It converts N+1 queries from a silent production performance problem into a loud error at the moment the code is written, and it is usually worth more than every subsequent optimisation combined.

Facades or dependency injection?

Both, in different places. Facades read well in controllers and simple code where the dependency is obvious. Constructor injection in domain and service classes, where explicit dependencies matter for testing and for understanding what a class actually needs. A domain class using six facades has six hidden dependencies.

When should I use events?

When there are genuinely multiple independent consumers, or when the consumers belong to different parts of the system. With one consumer and an essential relationship, call the code directly — making it an event does not decouple anything, it just makes the flow harder to follow.

Which frontend approach should I choose?

The lightest one that meets the requirement, which for most business applications is server-rendered pages with targeted interactivity. Reach for a decoupled frontend when you have multiple clients, a separate frontend team, or an interface that is genuinely an application rather than a set of pages — not because it is the modern default.

How do I stop jobs causing duplicate side effects?

Make every job idempotent, because retries are guaranteed rather than exceptional. Check whether the effect already happened before causing it — a charge for this period, a message with this identifier — rather than assuming a job runs exactly once. Then monitor the failed jobs table, which is ignored in a remarkable number of applications.

What should a new Laravel team do first?

Enable strict mode, establish that every bug fix gets a failing test first, invest in factories, and resist adding architecture before there is a concrete problem. Those four cost nothing and prevent most of what makes Laravel codebases unpleasant after two years.

Key takeaways

  • Conventions are the leverage. Following them gives enormous productivity; fighting them costs more than a different framework would.
  • Enable strict mode. The highest-value single change in any Laravel application.
  • Move logic out of controllers and models into action classes as the application grows.
  • Queue anything slow, and make every job idempotent because retries will happen.
  • Authorise in policies, not in views — hiding a button is not preventing an action.
  • Do not add architecture before there is a problem. Indirection without benefit is the common failure.

Laravel's durability comes from making the ordinary parts of building a web application genuinely pleasant, which is a harder achievement than it sounds and a more valuable one. The applications that stay pleasant for years are the ones that used the conventions fully, added structure when the code asked for it rather than in advance, and treated performance discipline as a development-time setting rather than a production emergency.

Enjoyed this article?

Get more engineering insights from ELIVTECH — or talk to us about your project.

Get in touch