How I Structure a Laravel Project for Long-Term Maintenance

After a dozen Laravel builds, I've settled on a structure that scales cleanly from MVP to production. Here's what I actually use.

Shusanto ModakApril 12, 2026
How I Structure a Laravel Project for Long-Term Maintenance

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 mean enum.
  • 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.

Tags
#laravel#php#architecture#clean-code
// Comments

Join the conversation

Leave a comment

Your email will not be published.