Start from Laravel’s edges, not from DDD cosplay
The framework’s request lifecycle is clear: HTTP → middleware → controller → response. Your job is to keep that edge thin: authorize, validate, call something that encodes the business rule, return a resource or view. When controllers grow past a screenful, the fix is rarely “more abstractions tomorrow”—it is extracting a named service or action today so the next developer sees intent instead of procedure.
You do not need a Domain/ tree on day one. You do need a rule: if two controllers repeat the same multi-step workflow, that workflow gets a home—often app/Services, app/Actions, or a small module namespace once the app crosses a complexity threshold.
Organize by feature when folders stop fitting in one screen
Flat app/Http/Controllers works until it does not. A pragmatic middle ground—before full modular monolith packages—is feature-oriented subfolders: Invoicing/, Billing/, Catalog/, each holding controllers, requests, and optionally services specific to that slice. Navigation in the IDE becomes self-documenting; code review stays localized.
For larger products, Laravel modules (via packages like nwidart/laravel-modules or internal Composer path repos) can pay off when teams own boundaries. The failure mode is premature modularization—split when ownership and deploy pain demand it, not when the diagram looks elegant.
Form requests and API resources are contracts
Form requests centralize validation and authorization hooks for a single use case. Treat them as the public contract of an endpoint: what shape of input is legal, and under which policy. That keeps controllers readable and gives you a natural place to add custom messages, after-validation logic, and prepared data without bloating the controller.
For JSON surfaces, API resources (and in Laravel 13+, first-party JSON:API resources when you need spec compliance) keep response shapes stable while models evolve. Couple that with versioned routes when external clients depend on you—your internal refactor should not be their surprise Friday outage.
Eloquent is the persistence layer, not the entire app
Models with accessors, mutators, casts, and scopes are appropriate. Models that orchestrate payment capture, PDF generation, and Slack notifications are not. Push cross-aggregate workflows into services or actions; keep models focused on data integrity, relationships, and query helpers.
Watch N+1 queries and hidden lazy loads in loops—scalability dies in the query log before it shows up in CPU charts. Use eager loading deliberately, select() only needed columns for heavy tables, and indexes where the ORM will actually filter or join.
Side effects belong in jobs, events, and listeners—on purpose
User-facing requests should feel fast. Mail, webhooks, image processing, report generation, and third-party API calls belong behind queues when latency or reliability matters. Structure jobs so they are idempotent where possible, log context on failure, and respect retries and timeouts (Laravel’s job attributes and, on newer releases, centralized queue routing help keep that consistent).
Domain events shine when multiple subsystems react to the same fact (“order paid”) without the checkout controller knowing every subscriber. Keep listeners thin; heavy work still goes to queued listeners or dedicated jobs.
Policies and middleware are your security spine
Authorization scattered across Blade, controllers, and random helpers rots quickly. Prefer policies for model-centric rules and gates for global capabilities. For multi-tenant SaaS, combine policies with tenant resolution middleware and scoped queries so a forgotten where clause cannot leak another customer’s rows.
Configuration and environment discipline
Treat config/ as the source of truth consumed by the app; .env as deployment-specific overrides. Avoid calling env() outside config files—cached config in production will bite you. Document required keys in README or a checked-in .env.example so onboarding is not archaeology.
Testing layout that matches how you ship
A maintainable suite usually mixes:
- Feature tests for HTTP contracts and critical user journeys.
- Unit tests for pure services, pricing rules, and state machines.
- Database transactions or RefreshDatabase where isolation matters—pick a strategy and stick to it per test type.
Mirror your application structure loosely in tests/ so failing tests point to the right neighborhood. Flaky tests are debt—fix or delete; they train the team to ignore CI.
Observability and operations are part of structure
Scalable code still fails on disk full, bad deploy order, or a poisoned queue. Standardize logging context (request id, user id, tenant id), health checks, and meaningful queue names. Laravel’s scheduler, Horizon for Redis queues, and structured logs make night pages rare enough that you can focus on product again.
Checklist before you call the structure “done”
- Can a new developer find where business rules live without reading half the app?
- Are validation, authorization, and response shape obvious at the HTTP boundary?
- Are slow and fragile operations off the request thread?
- Do tests protect the workflows that pay the bills?
- Is multi-tenancy or sensitive data enforced by default, not by convention?
The best Laravel structure for long-term maintenance is the smallest one that still answers “yes” to those questions. Everything else is a trade you should make deliberately when the pain appears—not when the blog post says you are ready.