How to Structure a Laravel Project That Survives Its Second Year
Most Laravel apps do not collapse because of the framework. They collapse because every new feature went into a controller. Here is a structure that still makes sense two years in.
A good Laravel project structure is not about having more folders. It is about making the next change obvious. Most applications that become painful did not fail because of the framework — they failed because every new feature was bolted onto a controller until nobody could tell what the code did.
Start with the default structure, and mean it
Laravel's defaults are good. Do not reorganise everything on day one because a blog post told you to use hexagonal architecture. For the first few thousand lines, controllers, models and requests are enough. Premature structure costs just as much as no structure, and it is harder to undo.
The moment to add structure is when you feel a specific pain. Not before.
Move logic out when a controller starts making decisions
A controller should receive a request, hand the work to something, and return a response. When it starts branching on business rules — checking statuses, calculating totals, deciding who gets access — that logic belongs somewhere it can be tested and reused.
Two patterns cover almost everything:
- Action classes for a single operation, such as EnrollStudentInCourse. One public method, obvious name, easy to test.
- Service classes when several operations share state or configuration, such as a payment gateway client.
If you only remember one rule: a method that would be hard to describe in a sentence is doing too much.
Keep models about data, not workflow
Models are a great place for relationships, scopes, casts and small derived values. They are a bad place for sending email, calling APIs or orchestrating multi-step processes. A model that dispatches jobs in a saved event will eventually surprise you during a seeder or a test.
Let the database enforce what matters
Validation in a form request protects the user experience. Constraints in the database protect the data. You want both. Unique indexes, foreign keys and sensible defaults catch the bugs that only appear under concurrency — the ones that are almost impossible to reproduce locally.
Queue anything the user should not wait for
Email, file processing, third-party API calls and report generation all belong on a queue. The test is simple: if the operation can fail for reasons outside your control, do not make a user watch a spinner while it happens. Queue it, make it idempotent, and let it retry.
Write tests for the parts that cost money
You do not need full coverage. You do need tests around access control, payments and anything that grants a user something. Those are the bugs that are expensive in public. A handful of feature tests that hit real routes will catch more than a hundred unit tests on getters.
What this looks like in practice
By the time an application is mature, a healthy structure usually looks like: thin controllers, form requests for validation, actions or services for business operations, jobs for anything slow, events for side effects other parts care about, and models that mostly describe data.
That is not an architecture you need to design up front. It is what you arrive at by moving code out of controllers each time it gets uncomfortable.
If you want to see this built end to end rather than described, our Laravel courses walk through the same decisions on a real project, and the starter kits in our store ship with this structure already in place.
Admin PenduCoder
Full-stack developer and educator. Writes about Laravel, Next.js, applied AI and the unglamorous parts of shipping software.
Get in touch →