Index

A Valid Command Can Still Be Refused

Send this cancellation request to two orders:

{
  "reason": "customer_request"
}

The placed order returns 204 and becomes cancelled. The shipped order returns 409 and remains shipped. Both requests pass the same Laravel Form Request.

Change the reason to unknown and the result is different again: Laravel returns 422 before the controller constructs a command. Three requests crossed two different decision boundaries. Calling one result “valid” and the others “invalid” hides the part of the system which made each decision.

Input validation can prove that a request has the shape and values needed to construct a command. It cannot promise that current domain state permits the command to succeed. A command is a request to attempt work, not a certificate that the work will be accepted.

The word valid is doing four jobs

A cancellation request raises four separate questions:

QuestionBoundary in this example
Can the payload become known application values?Laravel Form Request
May this actor request a cancellation?authorization policy
Can this order be cancelled in its current state?Order::cancel()
Did the accepted change become durable?database transaction

Those answers can differ. A recognized cancellation reason says nothing about the actor. An authorized actor can still target an order which has shipped. An allowed in-memory transition can still be rolled back when persistence fails.

The reproducible Laravel fixture isolates those questions. Its authorization method returns true so identity does not affect the result. That is an experimental control, not production authorization advice.

Laravel’s Form Request documentation places authorization and validation before the controller method. For JSON requests, a validation failure produces a 422 response. The fixture gives the request one representation rule:

final class CancelOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'reason' => [
                'required',
                Rule::enum(CancellationReason::class),
            ],
        ];
    }
}

With reason=unknown, the retained trace is empty and the retained command is null:

response=422
before=placed
after=placed
trace=[]
command=null

Removing the enum rule changes that result to a 500 response when CancellationReason::from('unknown') raises a ValueError. That negative control establishes what the Form Request protects: unknown transport data does not reach command construction. It does not establish whether an order may change state.

The command carries intent, not current state

Once the request passes, the controller constructs an immutable application message from the validated value:

final readonly class CancelOrder
{
    public function __construct(
        public OrderId $orderId,
        public CancellationReason $reason,
    ) {}
}

There is no Order snapshot and no cached canCancel flag. Either would turn an observation made earlier into a claim about state during handling.

Microsoft’s application-layer guidance describes a command as a request which may be refused. Its examples use .NET, but the lifecycle maps cleanly to this Laravel flow: receive a command, load the aggregate, execute domain behavior, and persist the result. The fixture tests that mapping rather than borrowing its implementation.

The same command can therefore remain perfectly well formed while the order it names changes. The controlled stale-state case makes one CancelOrder while the row is placed, changes the row to shipped, and then passes the same object to the handler:

command constructed ── order=placed
          ├── persisted state changes to shipped
          └── same command handled
                    └── OrderCannotBeCancelled: shipped

The row remains shipped. This is not a reproduction of a production race scheduler, nor does SQLite establish MySQL or PostgreSQL locking behavior. It demonstrates the narrower fact needed here: message shape can remain unchanged while authoritative state changes.

Transition authority needs the state being changed

The handler loads that state inside the transaction and asks the order to perform the transition:

final readonly class CancelOrderHandler
{
    public function __construct(private Orders $orders) {}

    public function handle(CancelOrder $command): void
    {
        DB::transaction(function () use ($command): void {
            $order = $this->orders->get($command->orderId);
            $order->cancel($command->reason);
            $this->orders->save($order);
        });
    }
}

The order owns the rule and the mutation:

public function cancel(CancellationReason $reason): void
{
    if ($this->status !== OrderStatus::Placed) {
        throw OrderCannotBeCancelled::from(
            $this->id,
            $this->status,
        );
    }

    $this->status = OrderStatus::Cancelled;
    $this->cancellationReason = $reason;
}

That is the same boundary developed in Tell, Don’t Ask Means Keep the Decision with the State: a caller may coordinate the use case, but it should not reproduce the state transition from exposed properties.

The retained cases show where the decision occurred:

CaseHTTPTracePersisted state
unknown reason, placed order422noneplaced
recognized reason, placed order204command → handler → domaincancelled
recognized reason, shipped order409command → handler → domainshipped

The accepted and refused cases have the same trace. Both reach Order::cancel(). Current order state changes the outcome.

Remove the status guard and the shipped-order case returns 204 and persists cancelled. The request rule still passes, command construction still works, and the handler still runs. Only the missing domain decision exposes the defect. That control is why the example supports a boundary claim rather than merely presenting a preferred class arrangement.

Earlier state checks are feedback, not authority

A database-backed Form Request rule can reject a shipped order before command construction. That may be useful. It can avoid work and return a more immediate error through the request boundary.

It is still an earlier read. If the state can change between that read and the write, the handler must not treat the precheck as permission. The fixture’s unsafe comparison does exactly that:

precheck reads placed
        ├── row changes to shipped
        └── code trusts the old answer and writes cancelled

The corresponding test fails because the unsafe path overwrites shipped with cancelled. Keep an early check when its feedback is worth the query, but repeat the invariant where the transition is applied.

The same limit applies to command-validation middleware. A bus can centralize value construction, authorization, logging, and other dispatch concerns. It cannot make a state-dependent answer remain true after the state changes. That trade belongs in the next article on whether a command bus earns its hidden control flow.

Value objects make a different improvement. CancellationReason prevents the application command from containing an unknown reason. It cannot make customer_request compatible with every future order state. Value validity and aggregate validity are related, but they answer questions at different scopes.

Sometimes one conditional write is the whole model

An aggregate and repository are not mandatory ceremony. If the complete rule is one status predicate on one row, an atomic conditional update may state it more directly:

update orders
set status = 'cancelled', cancellation_reason = ?
where id = ? and status = 'placed';

In that design, one affected row means the transition was accepted and zero means it was not. The database statement sees the predicate and performs the write as one operation. There is no benefit in rebuilding an aggregate merely to disguise that fact.

Reverse toward an aggregate when cancellation depends on several values, creates domain facts, or belongs to a larger consistency boundary. Reverse toward a database constraint when the database can express the invariant directly. The deciding question is not whether domain methods look cleaner. It is which boundary can evaluate the complete rule while applying the change.

What Repositories Really Do covers the concurrency and transaction contract required when the aggregate path is the right one. This SQLite fixture proves its own persisted outcomes and rollback; it does not choose a locking strategy for another database.

Refusal and failure need separate outward meanings

The fixture maps OrderCannotBeCancelled to an HTTP 409 response at the web edge. The domain exception contains an order identifier and status, not an HTTP code. That keeps transport policy out of the transition.

409 is one reasonable mapping for this synthetic current-state conflict. It is not a rule that every domain refusal must use 409. An application may need 422, 403, 404, or a domain-specific response contract depending on what the caller is allowed to know and how it can recover.

An exception is not required either. A typed result can make expected refusal explicit:

$result = $order->cancel($command->reason);

if ($result instanceof CancellationRefused) {
    return $result;
}

Use a result when callers routinely branch on refusal. Use an exception when the codebase treats an attempted invalid transition as exceptional. Either choice can preserve the important boundary: the domain decides from current state, while the application or transport decides how to represent that decision.

There is still a fourth question after the domain accepts. The fixture’s repository writes cancelled and then throws. Laravel’s transaction documentation says an exception in the transaction closure rolls back the changes and is rethrown. The retained trace reaches repository_saved, yet the row after failure is still placed:

trace=command_constructed,handler_invoked,domain_invoked,repository_saved
exception=RuntimeException: forced after repository save
after=placed

Remove DB::transaction() and the same test leaves the row cancelled. Domain acceptance and durable success are not synonyms either.

Put each answer at the boundary which can know it

For an HTTP command, validate representation before construction and authorize the actor explicitly. Let the command name the attempted application work without caching a promise about current state. Load authoritative state inside the handling boundary, enforce the transition where that state changes, and make both refusal and rollback visible to the caller.

Earlier checks can improve feedback. Command middleware can remove repeated entry-point work. An atomic conditional update can replace an aggregate when one predicate is the entire invariant. Those alternatives reverse the code shape, but none changes the authority test: which component sees the relevant state at the moment the transition becomes durable?

A command which reaches that component may be understood, authorized, and still refused. That is not failed validation. It is the domain answering the question validation was never able to answer.