Every Laravel project starts clean and ends up a mess — unless you put some structural guardrails in place early. Here's how I actually structure Laravel projects after years of building them for clients.
Start with Laravel's defaults
The Laravel framework's default directory structure is already pretty good. My first rule is: don't fight the framework unnecessarily. New devs joining the project will expect standard Laravel conventions, so stick with them until you have a concrete reason to deviate.
The layers I care about
For any Laravel project beyond toy scale, I separate code into four layers:
1. HTTP layer (controllers, requests, resources)
Controllers stay thin. Their only job is to:
- Validate input (via
FormRequest) - Call a service or action
- Return a response (via
Resource)
If a controller method is more than 15 lines, something has escaped where it shouldn't.
public function store(StoreOrderRequest $request, CreateOrder $createOrder)
{
$order = $createOrder->execute($request->validated());
return new OrderResource($order);
}
2. Domain layer (services, actions, events)
This is where business logic lives. I use single-purpose actions — one class per operation. Something like app/Actions/Orders/CreateOrder.php.
Why single actions instead of fat service classes? Because they're easier to:
- Test in isolation
- Queue as jobs
- Reuse across different entry points (controller, command, scheduled task)
- Read (the name tells you exactly what it does)
3. Data layer (models, repositories, scopes)
Models represent data — they shouldn't contain business logic. Push query logic into model scopes or dedicated repository classes for anything complex.
I avoid the repository pattern unless I'm abstracting over multiple data sources. For a typical app with a single database, model scopes are simpler and more readable.
4. Infrastructure (jobs, notifications, external APIs)
Anything that talks to the outside world — email, payment gateways, third-party APIs — lives in dedicated classes. Wrap vendor SDKs in your own interface so you can mock them in tests.
The folders I add
Beyond the Laravel defaults, I usually create:
app/
Actions/ # Single-purpose business operations
Services/ # Wrappers around external APIs
Support/ # Small helpers
Enums/ # PHP 8.1+ enums for statuses
Queries/ # Complex query builders
Tests are not optional
I keep two levels of tests:
- Feature tests for every HTTP route. They hit the real database (via
RefreshDatabase) and verify the happy path and key failure paths. - Unit tests for every Action class and any non-trivial domain logic.
Controllers, requests, and resources rarely need their own unit tests if the feature tests cover them.
Database discipline
Rules I never break:
- Every column has a type and nullability. No text
CHAR(255)when you meanenum. - Foreign keys are always constrained.
foreignId('user_id')->constrained()->cascadeOnDelete(). - Migrations are forward-only. No
down()methods in production — if you need to revert, write a new migration. - Seeders cover realistic data, not just "test user 1" / "test user 2".
Queues and jobs
Anything that can be async should be async. I move to queues for:
- Email sending
- External API calls
- Image processing
- PDF generation
- Webhooks
Use sync driver in local dev, Redis in staging/production. Horizon is a must for anything beyond a handful of jobs.
Observability
From day one, every project has:
- Sentry for error tracking
- Laravel Telescope in staging (never prod)
- Laravel Pulse for production monitoring
- Structured logs — no
dd()left in the codebase - Request IDs in logs so you can trace one request through the system
The one rule that matters most
Write code your successor can read.
Laravel makes a lot of things easy, which tempts you to be clever. Resist the temptation. Prefer boring, explicit code over magic shortcuts. Six months from now, when you or a replacement is debugging at 2am, that boring code will feel like a gift.