Actions
Read when: writing or reviewing an Action.
Owns: the final class + public static function handle() shape, naming, grouping, DTO input,
transactions, locks, exceptions, resolving dependencies, and the CRUD templates.
See also: contracts-and-services.md · dtos.md ·
core-kernel.md · queries.md
TL;DR
final class X { public static function handle(...) }. Stateless. Never a constructor.- One Action, one operation. Names:
Get{Entity},Get{Entity}s,Create{Entity},Update{Entity},Delete{Entity}, or the natural verb (ActivateEmployee,AssignManager). NoActionsuffix. Get{Entity}sextendsGetDataAbstractand is built through::for().- The payload travels as a
*DataDTO. The entity it acts on stays a plain parameter. - Never validate inside an Action. That is the Form Request's job.
- A write that touches more than one row runs in
DB::transaction(). - Other domains are reached inside
handle()withapp(SomeContract::class). - The Action owns the Eloquent query. Services, controllers, jobs and commands never do.
- Orchestration (several Actions, loops, payload parsing) is an Action too, never a Service.
The shape
// ✅
final class GetEmployee
{
public static function handle(int $id): ?Employee
{
return Employee::query()->find($id);
}
}
GetEmployee::handle($id);
// ❌ instantiated, instance method, injected dependency, suffix
class CreateEmployeeAction
{
public function __construct(private UserService $users) {}
public function execute(array $input): Employee { /* ... */ }
}
Why static and constructor-free: an Action can be called from a Service, a job, a command, a listener or a test with no container wiring, and it cannot hold state between calls. A constructor also invites injecting Contracts, which hides what a call needs.
Naming and grouping
| Kind | Name |
|---|---|
| Read one | GetEmployee |
| Read many (filtered, paginated) or an aggregate | GetEmployees, GetEmployeeCountsByStatus (both extend GetDataAbstract) |
| Insert | CreateEmployee |
| Partial update | UpdateEmployee |
| Remove | DeleteEmployee |
| Domain operation | ActivateEmployee, ArchiveEmployee, SyncEmployeeProfile, SendEmployeeInvitation |
The namespace Domain\{Domain}\Actions\ already says "Action". When a domain holds several entities,
group by entity: Actions/Employee/CreateEmployee.php, Actions/Invitation/ResendInvitation.php. Why:
a folder per entity keeps forty Actions findable; the class name still says what it does.
Every Action takes a DTO
// ✅ one typed payload; adding a field touches one class
UpdateUser::handle($user, UpdateUserData::fromArray($request->validated()));
// ❌ loose scalars drift out of sync with every caller
UpdateUser::handle($user, $name, $email, $phone);
Prefer one UpdateUser with an all-nullable UpdateUserData over UpdateUserName +
UpdateUserEmail. Why: the write rules (only sent keys, reset email_verified_at when the address
changes) then live in one place. An Action that receives a raw external payload (a webhook body) takes it
as a typed array with a PHPDoc shape and builds DTOs itself.
Never validate in an Action
// ❌ the domain re-validates what the boundary owns
final class CreateUser
{
public static function handle(array $input): User
{
Validator::make($input, [...])->validate();
// ...
}
}
When a package owns the route and hands you a raw array with no Form Request in the path (an auth
package's "create user" hook), that adapter is framework glue. Put it in app/, validate there, and call
the Action. Why: it keeps validation out of src/Domain/. Checking a business rule ("an archived
employee cannot be edited") is not validation; it belongs in the Action or the entity's Abilities, and
fails with a domain exception.
Transactions
Any Action that writes more than one row wraps the writes in a transaction. Read-only Actions do not.
// ❌ the employee is committed even when a contact fails
$employee = Employee::create([...]);
$employee->contacts()->createMany([...]);
// ✅ atomic
return DB::transaction(function () use ($data) {
$employee = Employee::create((clone $data)->exclude(['contacts'])->toArray());
$employee->contacts()->createMany(array_map(fn (EmployeeContactData $contact) => $contact->toArray(), $data->contacts));
return $employee;
});
- Jobs and events that must only run on success are dispatched after commit; how a job does that is in architecture.md.
- Calling a remote system inside the transaction is right when the local row must not exist without the remote one: if the remote refuses, throw and the row rolls back. Send your own id as the remote's external id. Why: a retry after a lost answer then finds the same remote record instead of making two.
- Never wrap a slow remote call in a transaction that holds hot rows locked. Queue it instead.
Concurrency
| Risk | Use |
|---|---|
| Two requests run the same operation on one record (double approve, double activate) | LockTransactionRunner::run('employee_activate', (string) $employee->id, fn () => ...) |
| A gapless number per key (employee numbers) | app(SequenceContract::class)->incrementCount("employee:{$year}") |
| Read, then write based on what was read | ->lockForUpdate() on the read, inside the transaction |
Why: a transaction alone does not stop two requests reading the same "pending" status and both acting on it. The helpers are in core-kernel.md.
Failing
if (! $employee->ability('canActivate')) {
throw EmployeeException::cannotActivate();
}
An Action throws a domain exception with a named constructor; it never returns a response, a redirect or
false for failure. Why: the exception renders the right status and message for JSON and pages alike
(core-kernel.md). Add @throws to the docblock only when the signature cannot
say it.
Resolving dependencies
final class ActivateEmployee
{
public static function handle(Employee $employee): Employee
{
return DB::transaction(function () use ($employee) {
$user = app(UserContract::class)->getUser($employee->user_id);
$number = NextEmployeeNumber::handle();
$employee->update(['employee_number' => $number, 'email' => $user->email, 'status' => EmployeeStatus::ACTIVE, 'activated_at' => now()]);
app(AuditContract::class)->record('employee.activated', $employee->id);
SendEmployeeWelcomeEmail::dispatch($employee);
return $employee;
});
}
}
NextEmployeeNumberandSendEmployeeWelcomeEmailare in the same domain, so they are imported directly.UserContractandAuditContractare other domains, so they go throughapp().- Who is acting comes from
app(CurrentUser::class), neverauth()(architecture.md).
Orchestration belongs in an Action
When an operation calls several Actions or Contracts, loops over input, or parses a payload, that is an Action (often a domain verb). The Service method stays a one-line delegation to it.
final class ProcessMailWebhook
{
/** @param array<int, array{type: string, invitation_id: string}> $events */
public static function handle(array $events): void
{
foreach ($events as $event) {
match ($event['type']) {
'email.delivered' => MarkInvitationDelivered::handle($event['invitation_id']),
'email.bounced' => MarkInvitationBounced::handle($event['invitation_id']),
default => null,
};
}
}
}
Why: a loop buried in a Service cannot be reused from a job or command without going through the whole Contract stack, and it is the one layer tests rarely cover.
Private helpers over single-use classes
A step that only one Action needs stays a private static method in that Action. A new Action earns its
file through real reuse (more than one caller), not readability alone.
Templates
final class Get{Entity}
{
public static function handle(int $id): ?{Entity}
{
return {Entity}::query()->find($id);
}
}
When callers look one up by something other than the id, widen the same Action instead of adding
Get{Entity}ByNumber:
public static function handle(?int $id = null, ?Filter{Entity}Data $filters = null, array $withRelations = []): ?{Entity}
{
if ($id === null && $filters === null) {
return null;
}
return {Entity}::query()->with($withRelations)->when($id, fn (Builder $query) => $query->whereKey($id))->filterBy($filters)->first();
}
final class Get{Entity}s extends GetDataAbstract
{
public function query(): Builder
{
return {Entity}::query()->filterBy($this->data);
}
}
final class Create{Entity}
{
public static function handle(Create{Entity}Data $data): {Entity}
{
return {Entity}::create($data->toArray());
}
}
final class Update{Entity}
{
public static function handle({Entity} $entity, Update{Entity}Data $data): {Entity}
{
return DB::transaction(function () use ($entity, $data) {
$entity->update((clone $data)->exclude(['contacts'])->excludeUnsent()->toArray());
if ($data->isSent('contacts')) {
$entity->contacts()->createOrUpdateOrDelete(array_map(fn (ContactData $contact) => $contact->toArray(), $data->contacts));
}
return $entity;
});
}
}
final class Delete{Entity}
{
public static function handle({Entity} $entity): void
{
$entity->delete();
}
}
Get{Entity}sreturns a builder holder, not rows; the caller pickspagination(),collection()orcount()(core-kernel.md). An aggregate (GetEmployeeCountsByStatus) uses the same base withselect()andgroupBy()inquery().Update{Entity}writes only the keys the caller sent, so an explicitnullclears a column and an absent key is left alone. Clone before narrowing, because the helpers change the same instance. Why and the rest of the sent-keys API: dtos.md.Create{Entity}with children cuts them out withexclude([...])and writes them in the same transaction, as in the Transactions section.find()returningnullvsfindOrFail()throwing a 404: pick per Action and let the return type say it (?EmployeevsEmployee).
Where Actions live
src/Domain/{Domain}/Actions/{Name}.php or Actions/{Entity}/{Name}.php, one class per file.
Verify
# Actions with a constructor
grep -rln "function __construct" src/Domain/*/Actions/
# Entry points that are not public static handle()
grep -rnE "function (handle|execute|__invoke|run)\(" src/Domain/*/Actions/ | grep -v "public static function handle"
# Validation or the session inside the domain
grep -rnE "Validator::|auth\(|request\(\)" src/Domain/ --include='*.php'
# Writes with no transaction (review each hit; single-row writes are fine)
grep -rL "DB::transaction" $(grep -rlE "::create\(|->update\(|->delete\(" src/Domain/*/Actions/ 2>/dev/null) 2>/dev/null
The first two list LockTransactionRunner::run() in Core; that helper is the one non-handle() entry.