Skip to content

Technical

Active Record and Actions in Laravel

Laravel already provides a powerful application architecture. By keeping persistence in Eloquent models and workflows in focused Actions, teams can add structure without burying straightforward features beneath unnecessary layers.

A practical guide to combining Eloquent’s Active Record pattern with focused Actions to build Laravel applications that stay clear, testable, and easy to extend.

Back to blog

Active Record and Actions in Laravel

Laravel gives developers a remarkably complete application foundation. It provides routing, validation, authentication, authorization, queues, events, database access, and a powerful ORM in one coherent ecosystem.

As applications grow, teams often feel pressure to introduce more architectural layers: repositories, data mappers, service classes, domain entities, command buses, and custom abstractions around nearly every framework feature.

Sometimes those tools are exactly what a domain needs. But adding them by default can make ordinary application code harder to understand.

A practical Laravel architecture can remain structured without becoming over-engineered. Two ideas work particularly well together:

  • Eloquent models use the Active Record pattern to own persistence and model-local behavior.
  • Actions represent focused application workflows that coordinate those models.

Together, they create clear boundaries while remaining close to Laravel.

This approach is also DDD-ready. It does not force every application to begin with full Domain-Driven Design, but it creates the boundaries needed to introduce stronger domain concepts later when the business actually requires them.

Start with Laravel’s Existing Architecture

Laravel applications already have a natural request flow:

HTTP Request
    ↓
Form Request
    ↓
Controller
    ↓
Action
    ↓
Eloquent Model
    ↓
Database

Each part has a specific responsibility.

The Form Request validates and normalizes input. The controller coordinates the HTTP boundary and authorization. The Action performs a named workflow. The Eloquent model represents persisted state and owns its relationships, casts, scopes, and local behavior.

This structure is usually enough for a large number of application features.

The goal is not to avoid abstractions entirely. The goal is to add them when they solve a real problem rather than introducing them automatically.

Eloquent as the Active Record Layer

Active Record is an architectural pattern where an object represents a database record and also knows how to query, persist, update, and delete itself.

Eloquent models follow this pattern.

A model is not merely a passive data container. It can own:

  • Table configuration
  • Relationships
  • Attribute casting
  • Query scopes
  • Persistence configuration
  • Small state changes
  • Behavior that belongs to the record itself

For example, an invoice model might define its customer relationship, cast its status to an enum, and expose a meaningful method for marking itself as paid:

final class Invoice extends Model
{
    protected function casts(): array
    {
        return [
            'status' => InvoiceStatus::class,
            'due_at' => 'datetime',
        ];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function markAsPaid(): void
    {
        $this->update([
            'status' => InvoiceStatus::Paid,
            'paid_at' => now(),
        ]);
    }
}

This behavior belongs on the model because it changes the invoice’s own state. The model knows what it means for an invoice to become paid, and the operation does not require coordinating multiple external systems.

Using the model directly is not a shortcut or a failure to design properly. It is the intended Active Record boundary:

$invoice->markAsPaid();

That is often clearer than introducing an InvoiceRepository, an UpdateInvoiceStatusService, and a separate persistence mapper for a state change the model can express naturally.

What Belongs on an Eloquent Model?

A useful rule is that an Eloquent model should own behavior that is local to the record.

Good candidates include:

  • markAsPaid()
  • publish()
  • archive()
  • isExpired()
  • displayName()
  • Relationships such as customer() or items()
  • Scopes such as scopePublished()
  • Casts for dates, enums, and value-like attributes

The model should also own the details of how it is persisted. Code outside the model should not need to know every column involved in a local state change.

However, a model should not become the entire application layer. Workflows that coordinate several models, authorize an operation, open a transaction, send notifications, or call an external system belong somewhere else.

That is where Actions become useful.

Actions as Focused Workflows

An Action represents one named application use case.

Examples include:

  • CreateInvoiceAction
  • PublishPostAction
  • AssignRolePermissionsAction
  • CompleteCheckoutAction
  • SendPasswordResetNotificationAction

The name should describe the operation. An Action should not become a generic manager with unrelated methods such as create, update, delete, publish, and archive.

A focused Action accepts the explicit values required by its workflow:

final class PublishPostAction
{
    public function handle(Post $post): Post
    {
        return DB::transaction(function () use ($post): Post {
            $post->publish();

            return $post->fresh();
        });
    }
}

The Action coordinates the workflow. The model still owns the local state change:

final class Post extends Model
{
    public function publish(): void
    {
        $this->update([
            'status' => PostStatus::Published,
            'published_at' => now(),
        ]);
    }
}

This gives each layer a clear purpose:

  • The model knows how a post becomes published.
  • The Action knows that publishing is a named application operation and may require a transaction or secondary reactions.
  • The controller knows how to receive the request and return an HTTP response.

An Action may also coordinate multiple models:

final class CompleteCheckoutAction
{
    public function handle(User $user, Cart $cart): Order
    {
        return DB::transaction(function () use ($user, $cart): Order {
            $order = $user->orders()->create([
                'total' => $cart->total(),
                'status' => OrderStatus::Pending,
            ]);

            foreach ($cart->items as $item) {
                $order->items()->create([
                    'product_id' => $item->product_id,
                    'quantity' => $item->quantity,
                    'unit_price' => $item->unit_price,
                ]);
            }

            $cart->clear();

            return $order->fresh('items');
        });
    }
}

This is more than a single model-local mutation. It coordinates an order, order items, and a cart inside a transaction. An Action makes that workflow explicit and independently testable.

The Complete Request Flow

A typical write operation should remain easy to trace:

Form Request
    ↓
Validation and normalization
    ↓
Controller
    ↓
Authorization
    ↓
Focused Action
    ↓
Eloquent model and relationships
    ↓
Database
    ↓
HTTP, Inertia, or resource response

A controller might look like this:

final class PostController extends Controller
{
    public function store(
        StorePostRequest $request,
        CreatePostAction $createPost,
    ): RedirectResponse {
        $post = $createPost->handle(
            $request->user(),
            $request->validated(),
        );

        return to_route('posts.show', $post);
    }
}

The controller coordinates HTTP. It obtains the authenticated actor, uses the Form Request for validation, invokes the Action, and returns the response.

The Action receives only the values it needs:

final class CreatePostAction
{
    public function handle(User $author, array $attributes): Post
    {
        return $author->posts()->create($attributes);
    }
}

The authenticated user determines ownership. The request does not get to submit an arbitrary author_id and decide who owns the record.

This separation makes the system easier to reason about and protects important invariants at the correct boundary.

Where Other Laravel Components Fit

Active Record and Actions do not replace the rest of Laravel’s application structure. They provide a center of gravity for it.

Form Requests

Form Requests validate and normalize HTTP input.

They should not contain the entire application workflow, and they should not be passed into Actions. An Action should receive the validated values it actually needs.

Controllers

Controllers coordinate HTTP.

A controller should normally:

  1. Receive the request.
  2. Obtain the authenticated actor.
  3. Authorize the operation.
  4. Pass validated values to an Action.
  5. Return the appropriate response.

When a controller starts calculating prices, mutating several models, dispatching multiple workflows, and handling external integrations, the workflow probably belongs in an Action.

Policies

Policies answer authorization questions.

They should determine whether an actor may perform an operation on a model. They are especially important before mutating or revealing sensitive records.

Authorization should not rely only on frontend visibility or route naming. The backend policy remains the authoritative security boundary.

Resources

Resources define the client-facing data shape.

They should transform models into intentional API or frontend responses. They should not perform database queries or application workflows.

Services

Services are useful for focused supporting capabilities.

Examples include:

  • A pricing calculation service
  • A payment gateway integration
  • A report generation service
  • A reusable external API client

A service should have one clear responsibility. It should not become a second application layer that duplicates every Action.

Query Objects

Query objects are useful when query construction becomes large, reusable, or difficult to understand inside a controller or Action.

They are not required for every latest()->get() call. Actions can query Eloquent directly for ordinary reads.

Common Architectural Mistakes

Fat Controllers

A controller becomes difficult to maintain when it owns validation, authorization, business rules, persistence, notifications, and integrations all at once.

Move the named workflow into an Action and leave the controller responsible for HTTP coordination.

Anemic Models

A model becomes anemic when every meaningful state change is expressed elsewhere as raw attribute mutation:

$post->update([
    'status' => 'published',
    'published_at' => now(),
]);

If this state transition appears in several places, give the model a meaningful method:

$post->publish();

The model should protect its own local invariants.

Catch-All Services

A class such as OrderService with methods for creating, updating, deleting, refunding, exporting, and emailing orders usually hides several different workflows behind one name.

Prefer focused Actions:

  • CreateOrderAction
  • RefundOrderAction
  • ExportOrderAction
  • SendOrderReceiptAction

This makes dependencies, tests, and responsibilities more obvious.

Hidden Workflow in Observers

Observers can be useful for secondary reactions, but essential application behavior should not be hidden inside them.

If an operation must happen for a workflow to be correct, keep it visible in the Action. A reader should be able to follow the important behavior by opening the Action rather than searching for observers, model events, and implicit listeners.

Trusting Request Input for Ownership

Ownership should come from trusted context whenever possible.

For example, a post creation request should not decide its own user_id. The authenticated user should be supplied to the Action by the controller:

$createPost->handle($request->user(), $request->validated());

This keeps authorization and ownership boundaries explicit.

Testing the Architecture

Laravel feature tests should be the primary confidence layer for application workflows.

A feature test can verify:

  • The request is authenticated.
  • The actor is authorized.
  • Invalid input is rejected.
  • The record is persisted.
  • Relationships are created correctly.
  • Important events are dispatched.
  • Another actor cannot access or mutate the record.
  • The response contains the expected data or redirect.

For example, a test for publishing a post should prove both the authorization boundary and the persisted state:

it('allows an authorized user to publish a post', function () {
    $user = User::factory()->create();
    $post = Post::factory()->for($user)->create();

    actingAs($user)
        ->post(route('posts.publish', $post))
        ->assertRedirect();

    expect($post->fresh())
        ->status->toBe(PostStatus::Published)
        ->published_at->not->toBeNull();
});

Actions may also be tested directly when doing so improves confidence. There is no need to introduce fake repositories merely to make an Action look like a unit test. An Action that uses Eloquent can use Laravel’s database testing support.

The test should follow the observable behavior of the application rather than testing implementation details that users never encounter.

A DDD-Ready Migration Path

Active Record and Actions are not the opposite of Domain-Driven Design.

They are a pragmatic starting point for Laravel applications that may eventually need stronger domain boundaries.

DDD should not be introduced as ceremony. It becomes valuable when the domain contains meaningful business language, complex invariants, multiple subdomains, long-running workflows, or rules that no longer fit comfortably inside a few models and Actions.

An Active Record and Actions architecture gives the application useful seams for that future evolution.

A gradual migration might look like this:

  1. Identify a cohesive business capability or subdomain.
  2. Group its Actions, models, policies, requests, and tests around that capability.
  3. Move repeated domain rules into explicit model methods or focused domain services.
  4. Introduce value objects where primitive values are no longer expressive enough.
  5. Introduce domain events when important business facts need to be communicated.
  6. Isolate external integrations behind focused interfaces when the domain requires replaceable infrastructure.
  7. Split bounded contexts only when the business language or ownership boundaries justify it.

The key is that the application does not need to begin with every DDD pattern. It can start with straightforward Laravel code and evolve as complexity becomes visible.

Actions are particularly useful during this migration. A named Action already represents a use case, which can later become an application-service boundary around richer domain objects.

Likewise, model methods make business rules visible before they need to move into aggregates, value objects, or domain services.

Being DDD-ready does not mean prematurely turning every Eloquent model into a domain entity. It means preserving enough clarity that stronger boundaries can be introduced without rewriting the entire application.

ShipKit as a Practical Example

ShipKit applies these principles as an opinionated Laravel starter foundation.

Its architecture keeps Laravel and Eloquent at the center:

  • Form Requests validate and normalize input.
  • Controllers coordinate HTTP and authorization.
  • Focused Actions represent named workflows.
  • Eloquent models own persistence, relationships, casts, scopes, and local behavior.
  • Policies enforce backend authorization.
  • React pages present server-provided state without replacing backend security.

The access-control features demonstrate this structure clearly. Creating or updating a role is a named workflow handled by an Action. Role and permission models remain Eloquent Active Record models. Policies decide whether the authenticated user may perform the operation. Feature tests verify authorization, validation, database state, and cross-user boundaries.

ShipKit is also intentionally DDD-ready. It does not force every generated project to adopt full Domain-Driven Design from the beginning. Instead, it provides clear application boundaries that can grow into feature modules, domain services, value objects, or bounded contexts when the project’s business complexity demands them.

ShipKit also keeps its web foundation separate from its API-enabled variant. The main branch provides the web application foundation with Fortify authentication. The codex/sanctum-api branch retains that foundation and adds versioned Sanctum bearer-token authentication for API clients.

The important point is that ShipKit is not trying to become another framework layered on top of Laravel. It provides a starting point with conventions that can be extended by the generated application.

Business features such as products, orders, appointments, invoices, or students belong in the project created from ShipKit. They should use the same architectural approach: models for persisted state, Actions for workflows, policies for authorization, and feature tests for observable behavior.

Start Laravel-Native

Active Record and Actions are not competing ideas.

Active Record gives persisted records a clear home for their data, relationships, and local behavior. Actions give application workflows a clear home when they coordinate more than one model or system.

The result is an architecture that is explicit without being ceremonial:

HTTP
    ↓
Actions
    ↓
Eloquent Models
    ↓
Database

Start with Laravel’s conventions. Keep controllers focused on HTTP. Let Eloquent remain the Active Record layer. Introduce Actions when an operation becomes a named workflow. Add DDD patterns when the domain creates a real need for them.

That balance keeps applications understandable today while leaving room for stronger domain boundaries tomorrow.