Skip to content

ADR-0011: Action Class Architecture

Accepted Cross-Project

Date: 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.php wrapping authEventLogger->log() calls.
  • entreezuil: 4 transactions across app/Http/Controllers/Auth/AuthenticatedSessionController.php and app/Http/Middleware/EnsureUserIsAdmin.php, all wrapping authEventLogger->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 classesfinal readonly classes with a single public execute() method. Controllers are thin: validate → delegate → respond.

Class Structure

php
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

RuleEnforcement
final readonly classtoBeFinal()->toBeReadonly()
Suffix ActiontoHaveSuffix('Action')
declare(strict_types=1)toUseStrictTypes()
No base classtoExtendNothing()
No interfacestoImplementNothing()
Exactly one public method: execute()Reflection test on public methods

Dependency Rules

RuleWhy
No facades (Illuminate\Support\Facades)Explicit injection, no hidden global state
No DatabaseManager — use ConnectionInterfaceInterface over implementation
No HTTP layer classes (Request, UploadedFile)Actions are HTTP-agnostic
No RuntimeExceptionUse domain-specific exceptions

Transaction Rules

RuleWhy
If Action mutates DB → must inject ConnectionInterfaceEnsures transactions are possible
If Action has ConnectionInterface → must call $this->db->transaction()No unused dependencies
All Eloquent mutations (save(), delete(), update(), sync()) inside transaction closuresAtomic operations
Transaction closures must have explicit return type (: void, : Issue, etc.)Prevents silent return value loss
No arrow functions (fn()) in transactionsArrow 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:

  1. Reflection-based — App\Http\* classes must not inject Illuminate\Database\ConnectionInterface or Illuminate\Database\DatabaseManager.
  2. 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

PatternCorrectWrong
Create new model$this->issue->newInstance()new Issue(), Issue::create()
Query models$this->issue->newQuery()->where(...)Issue::where(...), $this->issue::where(...)
Static through instanceNot allowed$this->model::where()

Controller Integration

Controllers inject the Action as a method parameter. Laravel's service container auto-resolves all constructor dependencies:

php
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:

php
final readonly class CreateIssueAction
{
    public function __construct(
        private ValidateBlockingRelationshipsAction $validateBlockingRelationships,
        private NotifyMentionedUsersAction $notifyMentions,
        private NotifyAssignmentChangeAction $notifyAssignment,
        private DetectBotAssignmentAction $detectBotAssignment,
        // ...
    ) {}
}

Return Patterns

PatternWhen
return ModelCreate operations — return the created entity
voidUpdate/delete — model modified in-place or removed
?ModelOptional lookup — returns null if not found
Throws exceptionValidation 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:

php
$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:

SourceConstruction
HTTP requestRequestContext::fromRequest($request) — captures IP, user agent, URL
MCP toolnew RequestContext(ipAddress: null, userAgent: 'MCP/' . class_basename(self::class), url: null)
System/CLIRequestContext::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.

php
public function __construct(
    #[Config('claude.default_templates')]
    private array $defaultTemplates,
) {}

Options Considered

OptionVerdictReason
Fat controllersRejectedNot reusable from jobs/commands/MCP, hard to unit test, accumulates complexity
Service classes (multiple public methods)RejectedGrow into god objects. Actions enforce single-responsibility via the one-public-method rule.
Model-level logic (observers/mutators)RejectedImplicit execution, hard to test, query-level operations bypass model events silently
Action classes (final readonly, one execute())AcceptedExplicit 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 readonly prevents 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

Implementation

TerritoryStateNotes
kendoEvergreenPattern 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-inventoryEvergreenPattern established. Architecture tests enforce conventions. 2026-04-24 amendment: HTTP-layer transaction audit not yet performed; reform impact pending reconnaissance.
ublgeniePartial13 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.
entreezuilPartial2026-04-24 amendment: 4 HTTP-layer transactions across AuthenticatedSessionController (3 call sites) + EnsureUserIsAdmin middleware (1 site) awaiting Phase 2 Engineer deployment.
emmieEvergreen2026-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-laboratoryEvergreenPattern established across experiments. 2026-04-24 amendment: HTTP-layer transaction audit not yet performed; reform impact pending reconnaissance.
daymate-apiNot Assessed2026-04-24 amendment: HTTP-layer transaction audit not yet performed; API is Laravel 9 on an upgrade trajectory — defer assessment until L12 upgrade lands.

Architecture documentation for contributors and collaborators.