Over time, I’ve settled on a feature-first, action-driven architecture for Laravel APIs:
Thin Controllers + Form Requests + Actions + API Resources (Command-style, one use case per class)
This structure scales well as applications grow and, importantly, it naturally aligns with the SOLID principles—without forcing patterns or over-engineering.
How the Pattern Maps to SOLID
Each layer has a single, clear purpose:
-
Single Responsibility (SRP)
- Form Requests: validation + authorization
- Actions: business logic
- Resources: response shaping
- Controllers: orchestration
- Models: persistence, relations, scopes
-
Open/Closed (OCP)
New behavior is added by introducing new Actions or Resources, not by modifying existing ones. -
Liskov Substitution (LSP)
Form Requests and Resources respect Laravel’s base contracts and can be substituted without breaking expectations. -
Interface Segregation (ISP)
Controllers depend only on narrow, purpose-built inputs (validated()data) and a single Action method (execute()). -
Dependency Inversion (DIP)
Controllers depend on Actions (application-level abstractions for business logic), not on database or model details.
Conceptual Folder Structure
app/
├── Actions/<Feature>/...
├── Http/
│ ├── Controllers/API/<Feature>Controller.php
│ ├── Requests/<Feature>/...
│ └── Resources/<Feature>/...
├── Models/
├── Traits/
database/
├── migrations/
routes/
└── api.php
- Actions: one class per use case
- Requests: validation and authorization
- Resources: public API contracts
- Controllers: thin coordinators
- Models: lean Eloquent models (relations, scopes, casts)
End-to-End Flow (Create Leave Example)
POST /api/leaves→LeaveController@storeLeaveStoreRequesthandles validation and authorization- Controller calls
LeaveStoreAction->execute($request) - Action performs the business logic (transaction if needed)
- Response is returned via
LeaveResource
Example Implementation
Migration
Schema::create('leaves', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('type');
$table->date('start_date');
$table->date('end_date');
$table->text('reason')->nullable();
$table->timestamps();
});
Model
class Leave extends Model
{
use HasFactory, LogsActivity;
protected $guarded = ['id'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeUpcoming($query)
{
return $query->whereDate('start_date', '>=', today());
}
}
Shared Trait
trait WithDbTransaction
{
protected function inTransaction(callable $callback)
{
return DB::transaction(fn () => $callback());
}
}
Form Request
class LeaveStoreRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', Leave::class) ?? false;
}
public function rules(): array
{
return [
'type' => 'required|string|max:50',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'reason' => 'nullable|string|max:1000',
];
}
}
Action
class LeaveStoreAction
{
use WithDbTransaction;
public function execute(LeaveStoreRequest $request): Leave
{
$data = $request->validated();
return $this->inTransaction(fn () =>
Leave::create([
...$data,
'user_id' => $request->user()->id,
])
);
}
}
API Resource
class LeaveResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'type' => $this->type,
'period' => [$this->start_date, $this->end_date],
'reason' => $this->reason,
'created_at' => $this->created_at?->toISOString(),
];
}
}
Controller
class LeaveController extends Controller
{
public function store(
LeaveStoreRequest $request,
LeaveStoreAction $action
) {
$leave = $action->execute($request);
return new LeaveResource($leave);
}
}
Route
Route::post('/leaves', [LeaveController::class, 'store']);
Why This Works in Practice
- Controllers stay small and readable
- Business logic lives in Actions, not controllers or models
- Each layer has one reason to change
- Behavior is extended by adding classes, not modifying existing ones
- The public API contract is explicitly defined via Resources
This results in a system that is predictable, testable, and resilient to growth.
Advantages
- Consistent structure across features
- Easy to unit-test Actions in isolation
- Feature-test controllers without mocking internals
- Reusable Actions across HTTP, jobs, and console commands
Trade-Offs to Watch
- Many small classes — keep feature folders organized
- Avoid overusing traits; keep them small and specific
- Choose
execute()or__invoke()and stay consistent - Clear config and route caches in CI to avoid stale state
Practical Guidelines
- Use verb-based Action names (
CreateLeave,ApproveLeave,CancelLeave) - Share validation logic via base Form Requests per feature
- Standardize API envelopes in a base controller or helper
- Wrap multi-write operations in transactions inside Actions
- Treat Resources as the public contract — never leak internal fields
This approach doesn’t fight Laravel — it leans into the framework while keeping application logic clean, explicit, and SOLID-friendly.