ADR-0011: Action Class Architecture
Accepted Cross-ProjectDate: 2026-03-08 Amended: 2026-04-24
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.
Amendment Context (2026-04-24)
Ublgenie PR #137 (audit deadlock retry, Medic) received a review from an ally flagging a nested-transaction savepoint gap in BulkDeleteInvoices. The review surfaced a larger class of outer-transaction owners: HTTP classes opening their own ->transaction( closures and calling audit loggers directly, bypassing the Action layer entirely.
A cross-territory sweep (see war-room campaign 2026-04-24-http-layer-transaction-ban.md) found:
- kendo: clean — zero HTTP-layer transactions, convention held.
- ublgenie: 3 transactions in
app/Http/Controllers/AuthController.phpwrappingauthEventLogger->log()calls. - entreezuil: 4 transactions across
app/Http/Controllers/Auth/AuthenticatedSessionController.phpandapp/Http/Middleware/EnsureUserIsAdmin.php, all wrappingauthEventLogger->log()calls.
All seven wrote to hash-chained audit tables (auth_event_logs) via AuditLogWriter::writeAuthEvent() — the exact InnoDB gap-lock vulnerability class that kendo KD-0448 and ublgenie #137 addressed in the Action layer — without the SQLSTATE 40001 retry mitigation. Because the existing deadlock-retry arch tests scope to app/Actions/*, these HTTP-layer transactions slipped past enforcement and created a latent concurrent-login deadlock exposure.
The original ADR-0011 (2026-03-08) said "every mutation wrapped in a database transaction" and "all business logic in Action classes" but was silent on who may open transactions. In practice kendo treated "Actions own transactions exclusively" as an implicit extension and enforced it by convention; ublgenie and entreezuil drifted. The amendment makes the implicit rule explicit: Actions are the sole owners of ->transaction( calls in application code, with AuditLogWriter as the documented infrastructure-layer exception.
The scope of this amendment is app/Http/* (Controllers and Middleware). Jobs (app/Jobs/*) and Services (app/Services/*) retain transaction-opening privileges for now; tightening the rule to those layers is deferred to a separate deliberation.
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 |
app/Http/* (Controllers, Middleware) must not inject ConnectionInterface / DatabaseManager, nor call ->transaction( | Actions are the sole transaction owners in application code (amended 2026-04-24) |
Transaction Ownership (amended 2026-04-24)
Actions are the sole owners of ->transaction( calls in application code. HTTP classes under app/Http/* — Controllers and Middleware — must not open transactions. If an HTTP entry point requires transactional work, it must delegate to an Action; the Action opens and owns the transaction.
AuditLogWriter is the documented infrastructure-layer exception. It writes the hash-chained audit rows but does not open its own transaction — its assertWithinTransaction() guard enforces that it runs inside a caller-owned transaction. This is by design: AuditLogWriter is transaction infrastructure, not business logic.
Middleware that needs to persist state (e.g., audit an access-denial event) must inject and call an Action rather than opening its own transaction. The middleware remains a thin gatekeeper; the Action carries the transaction, the audit write, and the deadlock-retry threading.
Rationale. Transaction ownership is a direct consequence of the Action pattern. Non-Action transaction owners create architecture-test enforcement gaps: deadlock-retry tests scoped to app/Actions/* cannot verify transactions opened in app/Http/*. The 2026-04-24 cross-territory reconnaissance (see campaign report) documented seven HTTP-layer transactions across ublgenie and entreezuil that wrote to hash-chained audit tables without the SQLSTATE 40001 retry mitigation. The amendment closes the class.
Enforcement. Two architecture tests per territory:
- Reflection-based —
App\Http\*classes must not injectIlluminate\Database\ConnectionInterfaceorIlluminate\Database\DatabaseManager. - Source-scan — PHP files under
app/Http/must not contain->transaction(occurrences.
Scope boundary. The amendment covers app/Http/* only. app/Jobs/* and app/Services/* may still open transactions under the original ADR-0011 rules. The broader "Actions are the sole transaction owner across all layers" reading is deferred to a separate deliberation — ublgenie currently has Jobs (PostInvoice, AnalyzeInvoice, FetchAnalysisResult) and a Service (LogicTokenService) that open transactions for non-hash-chained writes; those are out of scope for this amendment.
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 instead of the config() helper. See Config Attribute Injection for the full pattern, scope, and architecture test enforcement.
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
Implementation
| Territory | State | Notes |
|---|---|---|
| kendo | Evergreen | Pattern established. Architecture tests enforce final readonly, single execute(), no facades. 2026-04-24 amendment: clean by convention — zero HTTP-layer transactions. Phase 3 defensive arch test adoption planned (zero code moves). |
| brick-inventory | Evergreen | Pattern established. Architecture tests enforce conventions. 2026-04-24 amendment: HTTP-layer transaction audit not yet performed; reform impact pending reconnaissance. |
| ublgenie | Partial | 13 Actions extracted (PRs #7-9). final readonly + execute() established. Actions still use DatabaseManager (not ConnectionInterface), static model calls, and facades — internal compliance deferred. 2026-04-24 amendment: 3 HTTP-layer transactions in AuthController awaiting Phase 2 Engineer deployment. |
| entreezuil | Partial | 2026-04-24 amendment: 4 HTTP-layer transactions across AuthenticatedSessionController (3 call sites) + EnsureUserIsAdmin middleware (1 site) awaiting Phase 2 Engineer deployment. |
| emmie | Evergreen | 2026-04-27: All 316 Actions migrated from __invoke() to execute() (PR #181). final readonly, constructor DI, arch-test-enforced explicit return types. Sophisticated EnforceActionTransactionsRule PHPStan rule walks execute() to require transactions when ≥2 write-ops on database-typed dependencies. 2026-04-24 amendment: HTTP-layer transaction audit not yet performed; reform impact pending reconnaissance. |
| the-laboratory | Evergreen | Pattern established across experiments. 2026-04-24 amendment: HTTP-layer transaction audit not yet performed; reform impact pending reconnaissance. |
| daymate-api | Not Assessed | 2026-04-24 amendment: HTTP-layer transaction audit not yet performed; API is Laravel 9 on an upgrade trajectory — defer assessment until L12 upgrade lands. |