ADR-0011: Action Class Architecture
Accepted Cross-ProjectDate: 2026-03-08
Context
Laravel controllers tend to accumulate business logic over time — validation, authorization, database mutations, notifications, and side effects all end up in controller methods. This makes controllers hard to test in isolation, hard to reuse from non-HTTP contexts (queued jobs, artisan commands, MCP tools), and hard to reason about.
The project needs a pattern that:
- Separates business logic from HTTP concerns
- Is independently unit-testable with mocked dependencies
- Works from any entry point (controller, job, command, MCP tool)
- Enforces explicit dependency injection (no global state, no facades)
The Problem for AI Agents
Standard Laravel tutorials put logic in controllers and use facades (DB::transaction(), Auth::user()). Without documentation, AI agents will generate controller-heavy code with facades and implicit global state — violating every convention in these codebases.
Decision
All business logic lives in Action classes — final readonly classes with a single public execute() method. Controllers are thin: validate → delegate → respond.
Class Structure
declare(strict_types=1);
final readonly class CreateIssueAction
{
public function __construct(
private ConnectionInterface $db,
private Issue $issue,
private IssueAuditLogger $auditLogger,
private NotifyMentionedUsersAction $notifyMentions,
) {}
public function execute(Project $project, User $user, SaveIssueData $data, RequestContext $requestContext): Issue
{
$issue = $this->db->transaction(function () use ($project, $user, $data, $requestContext): Issue {
$issue = $this->issue->newInstance();
$issue->title = $data->title;
$issue->description = $data->description;
$issue->lane_id = $data->laneId;
$issue->project_id = $project->id;
$issue->save();
$this->auditLogger->logCreated($issue, $user, $requestContext);
return $issue;
});
// Side effects (notifications) outside transaction — can safely fail
$this->notifyMentions->execute($issue, $user);
return $issue;
}
}Mandatory Rules
All rules are enforced by architecture tests (e.g., tests/Arch/ActionsTest.php).
Class Rules
| Rule | Enforcement |
|---|---|
final readonly class | toBeFinal()->toBeReadonly() |
Suffix Action | toHaveSuffix('Action') |
declare(strict_types=1) | toUseStrictTypes() |
| No base class | toExtendNothing() |
| No interfaces | toImplementNothing() |
Exactly one public method: execute() | Reflection test on public methods |
Dependency Rules
| Rule | Why |
|---|---|
No facades (Illuminate\Support\Facades) | Explicit injection, no hidden global state |
No DatabaseManager — use ConnectionInterface | Interface over implementation |
No HTTP layer classes (Request, UploadedFile) | Actions are HTTP-agnostic |
No RuntimeException | Use domain-specific exceptions |
Transaction Rules
| Rule | Why |
|---|---|
If Action mutates DB → must inject ConnectionInterface | Ensures transactions are possible |
If Action has ConnectionInterface → must call $this->db->transaction() | No unused dependencies |
All Eloquent mutations (save(), delete(), update(), sync()) inside transaction closures | Atomic operations |
Transaction closures must have explicit return type (: void, : Issue, etc.) | Prevents silent return value loss |
No arrow functions (fn()) in transactions | Arrow functions silently discard return values |
Model Usage Rules
| Pattern | Correct | Wrong |
|---|---|---|
| Create new model | $this->issue->newInstance() | new Issue(), Issue::create() |
| Query models | $this->issue->newQuery()->where(...) | Issue::where(...), $this->issue::where(...) |
| Static through instance | Not allowed | $this->model::where() |
Controller Integration
Controllers inject the Action as a method parameter. Laravel's service container auto-resolves all constructor dependencies:
public function store(
SaveIssueRequest $request,
Project $project,
#[CurrentUser] User $user,
CreateIssueAction $action,
): JsonResponse {
$issue = $action->execute($project, $user, $request->toDto(), RequestContext::fromRequest($request));
return new IssueResource($issue->load(IssueResource::EAGER_LOAD))->response()->setStatusCode(201);
}Action Composition
Actions can inject and call other Actions. This replaces service classes and event-driven patterns:
final readonly class CreateIssueAction
{
public function __construct(
private ValidateBlockingRelationshipsAction $validateBlockingRelationships,
private NotifyMentionedUsersAction $notifyMentions,
private NotifyAssignmentChangeAction $notifyAssignment,
private DetectBotAssignmentAction $detectBotAssignment,
// ...
) {}
}Return Patterns
| Pattern | When |
|---|---|
return Model | Create operations — return the created entity |
void | Update/delete — model modified in-place or removed |
?Model | Optional lookup — returns null if not found |
| Throws exception | Validation guards — InvalidArgumentException on failure |
Audit Logging Integration
Actions that mutate auditable models must inject the corresponding audit logger and call it inside the transaction. For updates, snapshot the old state before the transaction:
$oldValues = $this->auditLogger->snapshotIssue($issue);
$this->db->transaction(function () use ($issue, $user, $requestContext, $oldValues): void {
$issue->title = $data->title;
$issue->save();
$this->auditLogger->logUpdated($issue, $user, $requestContext, $oldValues);
});See Audit Logging for the full audit system design.
RequestContext
Every audited Action receives a RequestContext DTO capturing the origin of the request:
| Source | Construction |
|---|---|
| HTTP request | RequestContext::fromRequest($request) — captures IP, user agent, URL |
| MCP tool | new RequestContext(ipAddress: null, userAgent: 'MCP/' . class_basename(self::class), url: null) |
| System/CLI | RequestContext::system() — all fields null |
Config Injection
Actions that need configuration values use the #[Config] attribute:
public function __construct(
#[Config('claude.default_templates')]
private array $defaultTemplates,
) {}Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Fat controllers | Rejected | Not reusable from jobs/commands/MCP, hard to unit test, accumulates complexity |
| Service classes (multiple public methods) | Rejected | Grow into god objects. Actions enforce single-responsibility via the one-public-method rule. |
| Model-level logic (observers/mutators) | Rejected | Implicit execution, hard to test, query-level operations bypass model events silently |
Action classes (final readonly, one execute()) | Accepted | Explicit DI, no global state, composable, testable, 15+ architecture tests enforce the pattern |
Consequences
Positive
- Business logic is independently testable — mock dependencies, call
execute(), assert results - Actions work from any entry point — controllers, jobs, commands, MCP tools all call the same Action
final readonlyprevents inheritance hierarchies and mutable state- Architecture tests catch violations at CI — AI-generated code that uses facades or skips transactions fails immediately
- Composition over inheritance — Actions call other Actions, building complex flows from simple units
Negative
- More files per feature: Controller + FormRequest + DTO + Action + Test (5 files minimum)
- Verbose: explicit dependency injection means long constructor parameter lists
- AI agents unfamiliar with this pattern will generate facade-based code that fails architecture tests
- New developers must learn the Action pattern before contributing
References
- Architecture tests:
tests/Arch/ActionsTest.php - Audit Logging — defines audit logger integration in Actions
- FormRequest → DTO Flow — defines how data enters Actions