How I Structure Every Laravel Project
A Starting Point, Not a Standard
Let me be clear upfront: this is how I structure my Laravel projects. It's not the One True Way. It's the result of years of iteration, mistakes, refactoring, and learning what works for the kinds of applications I build. Your mileage may vary, and that's fine.
That said, I've used this structure on enough projects, solo and on teams, and I'm confident it scales well from small apps to medium-large ones. It's not exotic. It doesn't require you to learn a whole new paradigm. It's just Laravel, organized intentionally.
The Folder Structure
Here's what my app/ directory typically looks like:
app/
├── Actions/
├── DTOs/
├── Enums/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ └── Requests/
├── Models/
├── Policies/
├── Providers/
├── Services/
├── Queries/
└── View/
└── Components/
Nothing here should scare you. Most of these are standard Laravel. The additions (Actions, DTOs, Enums, Services, Queries) are just conventions for organizing code that would otherwise end up crammed into controllers or models.
Controllers: Thin and Boring
My controllers are thin. Intentionally, almost aggressively thin. A controller method should do three things: validate input, call something that does the work, and return a response.
class SubscriptionController extends Controller
{
public function store(
CreateSubscriptionRequest $request,
CreateSubscription $action,
): RedirectResponse {
$action->handle(
user: $request->user(),
plan: Plan::from($request->validated('plan')),
);
return redirect()
->route('dashboard')
->with('success', 'Subscription created.');
}
}
That's it. The controller doesn't know how a subscription is created. It doesn't contain business logic. It delegates to a Form Request for validation and an Action for execution. If the business logic changes, the controller doesn't change. If the validation rules change, I update the Form Request. Everything has one reason to change.
I also lean toward resource controllers and single-action controllers (__invoke) for clarity. If a controller has more than five or six methods, something probably needs to be split up.
Form Requests: Validation Lives Here
Every form submission gets a Form Request. No inline validation in controllers. Ever.
class CreateSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->canSubscribe();
}
public function rules(): array
{
return [
'plan' => ['required', 'string', Rule::enum(Plan::class)],
];
}
}
Form Requests are one of Laravel's best features and I think they're underused. They handle validation and authorization in one clean class. They're automatically injected. They keep your controllers clean. Use them.
Actions: Single-Purpose Business Logic
This is the heart of my architecture. An Action is a class that does one thing. Not "manages subscriptions" but "creates a subscription." Singular, specific, testable.
class CreateSubscription
{
public function handle(User $user, Plan $plan): Subscription
{
$subscription = $user->subscriptions()->create([
'plan' => $plan,
'starts_at' => now(),
'ends_at' => now()->addMonth(),
]);
SendWelcomeEmail::dispatch($subscription);
SubscriptionMetrics::track($subscription);
return $subscription;
}
}
Why Actions instead of just putting this in the controller? Because this logic might need to be called from multiple places: a controller, a console command, a queued job, an API endpoint. If the logic lives in the controller, you end up duplicating it or doing awkward controller-to-controller calls. If it lives in an Action, anyone can call it.
Actions are also trivially easy to test:
it('creates a subscription for the given plan', function () {
$user = User::factory()->create();
$subscription = (new CreateSubscription)->handle(
user: $user,
plan: Plan::Monthly,
);
expect($subscription)
->toBeInstanceOf(Subscription::class)
->plan->toBe(Plan::Monthly)
->starts_at->toBeInstanceOf(Carbon::class);
expect($user->subscriptions)->toHaveCount(1);
});
That Pest test reads like a specification. You know exactly what it's testing and what the expected outcome is.
Services: When Actions Aren't Enough
Sometimes you need a class that coordinates multiple operations or wraps an external API. That's what the Services directory is for.
class StripeService
{
public function __construct(
private StripeClient $client,
) {}
public function createCustomer(User $user): string
{
$customer = $this->client->customers->create([
'email' => $user->email,
'name' => $user->name,
]);
return $customer->id;
}
public function charge(string $customerId, int $amount): PaymentIntent
{
return $this->client->paymentIntents->create([
'customer' => $customerId,
'amount' => $amount,
'currency' => 'usd',
]);
}
}
The distinction between Actions and Services is admittedly fuzzy, and I don't lose sleep over it. My rough guideline: Actions represent things your application does (business operations). Services represent integrations or utilities that support those operations.
DTOs: Structured Data Without the Guesswork
For anything more complex than a couple of parameters, I use Data Transfer Objects.
readonly class SubscriptionData
{
public function __construct(
public Plan $plan,
public ?Carbon $startsAt = null,
public ?string $couponCode = null,
) {}
public static function fromRequest(CreateSubscriptionRequest $request): self
{
return new self(
plan: Plan::from($request->validated('plan')),
startsAt: $request->validated('starts_at')
? Carbon::parse($request->validated('starts_at'))
: null,
couponCode: $request->validated('coupon_code'),
);
}
}
DTOs give you autocompletion, type safety, and a clear contract for what data a method expects. No more passing arrays around and hoping the keys are right. PHP 8.2's readonly classes make this pattern incredibly clean.
Enums: Because Magic Strings Are Evil
If a value has a fixed set of options, it's an enum. Period.
enum Plan: string
{
case Monthly = 'monthly';
case Yearly = 'yearly';
case Lifetime = 'lifetime';
public function price(): int
{
return match ($this) {
self::Monthly => 1500,
self::Yearly => 15000,
self::Lifetime => 50000,
};
}
}
No more if ($plan === 'monthly') scattered across your codebase. No more typos causing silent bugs. Enums are one of the best additions to PHP in years and I use them aggressively.
Queries: Reusable Query Logic
When I have complex Eloquent queries that are used in multiple places, I extract them into Query classes.
class ActiveSubscriptionsQuery
{
public function __invoke(Builder $query): Builder
{
return $query
->where('status', 'active')
->where('ends_at', '>', now());
}
}
These can be used as scopes, in pipelines, or called directly. They keep my models from becoming 500-line god classes, which is a trap I've fallen into more than once.
Testing With Pest
Testing is something I'm genuinely passionate about, and I've developed a strong opinion on how test suites should be organized. Every project gets a tests/ directory that mirrors the app/ directory structure, split into three distinct suites: Unit, Integration, and Feature.
tests/
├── Feature/
│ ├── Controllers/
│ │ ├── SubscriptionControllerTest.php
│ │ └── AuthControllerTest.php
│ └── Commands/
│ └── PruneExpiredSubscriptionsTest.php
├── Integration/
│ ├── Actions/
│ │ └── CreateSubscriptionTest.php
│ ├── Models/
│ │ └── SubscriptionTest.php
│ ├── Queries/
│ │ └── ActiveSubscriptionsQueryTest.php
│ └── Services/
│ └── StripeServiceTest.php
└── Unit/
├── DTOs/
│ └── SubscriptionDataTest.php
└── Enums/
└── PlanTest.php
The distinction between these three suites is important, and I see a lot of developers get it wrong.
Feature tests test the entry points into your application, like controllers and console commands. They test the full HTTP request/response cycle or the full command execution. These are your highest-level tests: "when a user hits this endpoint with this data, does the right thing happen?"
it('creates a subscription for the authenticated user', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/subscriptions', [
'plan' => 'monthly',
]);
$response->assertRedirect(route('dashboard'));
expect($user->subscriptions)->toHaveCount(1);
});
it('rejects invalid plan types', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/subscriptions', ['plan' => 'invalid'])
->assertSessionHasErrors('plan');
});
Integration tests are where you test how your code interacts with external systems, primarily the database, but also things like cache, queue, or third-party APIs. Models, query classes, actions, services that hit the database, and repository-style logic belong here. These tests use RefreshDatabase and actually read and write to a test database.
it('returns only active subscriptions', function () {
Subscription::factory()->active()->count(3)->create();
Subscription::factory()->expired()->count(2)->create();
$active = Subscription::query()
->tap(new ActiveSubscriptionsQuery)
->get();
expect($active)->toHaveCount(3);
});
Unit tests are true unit tests. They test a single class in complete isolation with zero external dependencies. No database, no file system, no HTTP calls, no queue. Nothing. If your "unit test" needs to migrate a database to pass, it's not a unit test. DTOs, Enums, and pure logic classes get unit tested. Mock or stub any dependencies.
it('creates subscription data from valid input', function () {
$data = new SubscriptionData(
plan: Plan::Monthly,
startsAt: Carbon::parse('2026-03-01'),
);
expect($data)
->plan->toBe(Plan::Monthly)
->startsAt->toBeInstanceOf(Carbon::class)
->couponCode->toBeNull();
});
it('calculates the correct price for each plan', function (Plan $plan, int $expected) {
expect($plan->price())->toBe($expected);
})->with([
[Plan::Monthly, 1500],
[Plan::Yearly, 15000],
[Plan::Lifetime, 50000],
]);
This three-suite approach gives me confidence at every level. Feature tests confirm the whole request pipeline holds together. Integration tests verify my data layer works. Unit tests catch logic bugs fast (and run in milliseconds). When something breaks, the failing test suite tells me where to look.
I use Pest exclusively. The syntax is cleaner, the output is better, and the expect() API makes assertions readable. If you're still using PHPUnit, give Pest a real try. Not a five-minute glance. Actually build something with it. I think you'll be surprised.
Filament for Admin
If the project needs an admin panel (and most do) I reach for Filament. It integrates with this structure beautifully. Filament resources can use the same Actions, Services, and DTOs that the rest of the application uses. You're not maintaining two separate sets of business logic.
I typically put Filament panels in a separate app/Filament/ directory (which is the default) and keep the admin logic thin, just like controllers. The real work still happens in Actions and Services.
What I Don't Do
A few things I've intentionally avoided:
- Repository pattern (in most cases). Eloquent is already an abstraction over database access. Wrapping it in another abstraction usually adds complexity without benefit. If you're building something that genuinely might switch databases, sure. But you're probably not.
- Over-interfacing. I don't create an interface for every class. Interfaces are great when you need polymorphism or when you're building a package. For application code, the concrete class is usually fine.
- Domain-Driven Design by default. DDD is powerful for complex domains, but it's overkill for most Laravel apps. I borrow concepts (like value objects and Actions), but I don't reorganize my entire application around bounded contexts unless the domain genuinely demands it.
The Point
Good structure isn't about following rules. It's about making your codebase navigable, testable, and maintainable. When a new developer joins the project, they should be able to find things. When you come back to code you wrote six months ago, you should be able to understand it.
This structure gives me that. It's Laravel, organized with intention. Nothing more, nothing less.
If you've got a structure that works better for you, I'd genuinely love to hear about it. Hit me up on social media or drop a comment. The whole point of writing this stuff down is to start conversations, not end them.
Continue Reading
Why I Still Choose Laravel in 2026
Every year someone declares PHP dead and Laravel irrelevant. Every year I start another Laravel project. Here's why I'm not switching.
What 15 Years of Web Development Taught Me
Fifteen years is a long time to do anything. Here are the lessons, technical and otherwise, that I wish someone had told me on day one.
From Kansas to Florida: A Developer's Journey
I grew up in Kansas, discovered the web in the late 2000s, and somehow ended up writing code full-time in Florida. This is that story.