DDD in Laravel: Value Objects, DTOs &amp; Actions | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com) [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles  ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://www.msaied.com) [ Projects ](https://www.msaied.com/projects) [ Articles ](https://www.msaied.com/articles) [ Certificates ](https://www.msaied.com/certificates) [ Contact ](https://www.msaied.com#contact-section) 

  [ home ](https://www.msaied.com)    [ articles ](https://www.msaied.com/articles)    Domain-Driven Design in Laravel: Value Objects, DTOs, and Actions Without Bloat        On this page       1. [  Why DDD Concepts Matter Without Going Full Framework ](#why-ddd-concepts-matter-without-going-full-framework)
2. [  Value Objects: Encapsulate Meaning, Not Just Data ](#value-objects-encapsulate-meaning-not-just-data)
3. [  DTOs: Typed Input Boundaries ](#dtos-typed-input-boundaries)
4. [  Single-Action Classes: One Class, One Job ](#single-action-classes-one-class-one-job)
5. [  Testing the Stack with Pest ](#testing-the-stack-with-pest)
6. [  Key Takeaways ](#key-takeaways)

  ![Domain-Driven Design in Laravel: Value Objects, DTOs, and Actions Without Bloat](https://cdn.msaied.com/377/b5c2aef77aed51ee8694f377085af424.png)

  #laravel   #ddd   #architecture   #php  

 Domain-Driven Design in Laravel: Value Objects, DTOs, and Actions Without Bloat 
=================================================================================

     6 Jul 2026      4 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

1. [  01   Why DDD Concepts Matter Without Going Full Framework  ](#why-ddd-concepts-matter-without-going-full-framework)
2. [  02   Value Objects: Encapsulate Meaning, Not Just Data  ](#value-objects-encapsulate-meaning-not-just-data)
3. [  03   DTOs: Typed Input Boundaries  ](#dtos-typed-input-boundaries)
4. [  04   Single-Action Classes: One Class, One Job  ](#single-action-classes-one-class-one-job)
5. [  05   Testing the Stack with Pest  ](#testing-the-stack-with-pest)
6. [  06   Key Takeaways  ](#key-takeaways)

 Why DDD Concepts Matter Without Going Full Framework
----------------------------------------------------

Domain-driven design gets a bad reputation for ceremony. Aggregates, repositories, domain events, bounded contexts — the vocabulary alone can paralyse a team. But the *core tactical patterns* — value objects, data transfer objects, and single-action classes — are lightweight enough to drop into any Laravel project today, and they pay dividends immediately in readability and testability.

This article focuses on those three patterns, how they interact, and where the common mistakes are.

---

Value Objects: Encapsulate Meaning, Not Just Data
-------------------------------------------------

A value object wraps a primitive and enforces its invariants at construction time. Once created, it is immutable.

```php
final class Money
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $currency,
    ) {
        if ($amountInCents < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative.');
        }

        if (!in_array($currency, ['USD', 'EUR', 'GBP'], true)) {
            throw new \InvalidArgumentException("Unsupported currency: {$currency}");
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \LogicException('Cannot add different currencies.');
        }

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->amountInCents === $other->amountInCents
            && $this->currency === $other->currency;
    }
}

```

Pair this with a custom Eloquent cast so the model layer stays clean:

```php
class MoneyCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes): Money
    {
        return new Money(
            (int) $attributes['amount_in_cents'],
            $attributes['currency'],
        );
    }

    public function set($model, string $key, $value, array $attributes): array
    {
        if (!$value instanceof Money) {
            throw new \InvalidArgumentException('Expected a Money instance.');
        }

        return [
            'amount_in_cents' => $value->amountInCents,
            'currency'        => $value->currency,
        ];
    }
}

```

Now `$order->total` is always a `Money` — never a raw integer you might accidentally display without formatting.

---

DTOs: Typed Input Boundaries
----------------------------

A DTO carries validated, typed data across a layer boundary. It is *not* a model, *not* a form request, and *not* a value object — it is a plain carrier.

```php
final readonly class PlaceOrderData
{
    public function __construct(
        public int    $customerId,
        public Money  $total,
        public string $shippingAddress,
        /** @var non-empty-list */
        public array  $lines,
    ) {}

    public static function fromRequest(PlaceOrderRequest $request): self
    {
        return new self(
            customerId:      $request->integer('customer_id'),
            total:           new Money($request->integer('total_cents'), $request->string('currency')),
            shippingAddress: $request->string('shipping_address'),
            lines:           array_map(
                fn(array $line) => OrderLineData::fromArray($line),
                $request->array('lines'),
            ),
        );
    }
}

```

Using `readonly` (PHP 8.2+) eliminates the need for getters and prevents mutation after construction.

---

Single-Action Classes: One Class, One Job
-----------------------------------------

Actions replace fat service classes. Each action does exactly one thing and declares its dependencies explicitly.

```php
final class PlaceOrderAction
{
    public function __construct(
        private readonly OrderRepository   $orders,
        private readonly PaymentGateway    $payments,
        private readonly EventDispatcher   $events,
    ) {}

    public function execute(PlaceOrderData $data): Order
    {
        $order = Order::create([
            'customer_id'      => $data->customerId,
            'total_in_cents'   => $data->total->amountInCents,
            'currency'         => $data->total->currency,
            'shipping_address' => $data->shippingAddress,
        ]);

        foreach ($data->lines as $line) {
            $order->lines()->create($line->toArray());
        }

        $this->payments->charge($order);
        $this->events->dispatch(new OrderPlaced($order));

        return $order;
    }
}

```

In the controller:

```php
public function store(PlaceOrderRequest $request, PlaceOrderAction $action): JsonResponse
{
    $order = $action->execute(PlaceOrderData::fromRequest($request));

    return OrderResource::make($order)->response()->setStatusCode(201);
}

```

Laravel's service container resolves `PlaceOrderAction` automatically. No manual wiring needed.

---

Testing the Stack with Pest
---------------------------

Because each layer is isolated, tests are small and fast:

```php
it('adds two money values of the same currency', function () {
    $a = new Money(1000, 'USD');
    $b = new Money(500, 'USD');

    expect($a->add($b)->amountInCents)->toBe(1500);
});

it('throws when adding different currencies', function () {
    $a = new Money(1000, 'USD');
    $b = new Money(500, 'EUR');

    expect(fn() => $a->add($b))->toThrow(\LogicException::class);
});

it('places an order and dispatches an event', function () {
    Event::fake([OrderPlaced::class]);

    $action = app(PlaceOrderAction::class);
    $data   = PlaceOrderData::fromRequest(fakeOrderRequest());

    $order = $action->execute($data);

    expect($order->total_in_cents)->toBe($data->total->amountInCents);
    Event::assertDispatched(OrderPlaced::class);
});

```

---

Key Takeaways
-------------

- **Value objects** enforce invariants at construction and eliminate primitive obsession.
- **DTOs** create a typed boundary between HTTP input and domain logic — use `readonly` to prevent mutation.
- **Single-action classes** keep domain logic discoverable, injectable, and independently testable.
- Custom Eloquent casts bridge value objects and the persistence layer without leaking domain rules into models.
- None of these patterns require a DDD framework — they are plain PHP classes resolved by Laravel's container.

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fdomain-driven-design-in-laravel-value-objects-dtos-and-actions-without-bloat&text=Domain-Driven+Design+in+Laravel%3A+Value+Objects%2C+DTOs%2C+and+Actions+Without+Bloat) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwww.msaied.com%2Farticles%2Fdomain-driven-design-in-laravel-value-objects-dtos-and-actions-without-bloat) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  Should every domain concept have a value object?        No. Reach for a value object when a primitive carries business rules — a currency amount, an email address, a percentage. Plain strings and integers are fine for identifiers and labels that have no invariants. 

      Q02  What is the difference between a DTO and a Form Request in Laravel?        A Form Request handles HTTP validation and authorization. A DTO is a plain PHP object that carries already-validated, typed data into the domain layer. Keeping them separate means your actions are not coupled to the HTTP layer and can be called from queued jobs or CLI commands too. 

      Q03  Can single-action classes replace service classes entirely?        For most use cases, yes. If you find yourself grouping three or four related actions that share significant state or helpers, a service class is still appropriate. The goal is cohesion, not dogma. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://www.msaied.com/articles) 

 [ ![Laravel queue:work Now Prints Why the Worker Stopped](https://cdn.msaied.com/629/bed93940d17b932032d64cee2e32d333.png) Laravel Queue Laravel 13.30 

### Laravel queue:work Now Prints Why the Worker Stopped

Laravel 13.30 adds a stop-reason line to queue:work output. Workers now print why they exited—memory limit, re...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 3 Sep 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/laravel-queuework-now-prints-why-the-worker-stopped) [ ![The Laracon Archive: 626 Talks, 287 Speakers, and 14 Years of Laravel History](https://cdn.msaied.com/628/13d8d0cef5fb083813df134cc253bb1b.png) laracon laravel community 

### The Laracon Archive: 626 Talks, 287 Speakers, and 14 Years of Laravel History

The Laracon Archive is a community-built, searchable index of every recorded Laracon talk — 626 talks from 287...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 3 Sep 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/the-laracon-archive-626-talks-287-speakers-and-14-years-of-laravel-history) [ ![Semantic Memory or Just Markdown? How Laravel Boost Manages AI Agent Project Rules](https://cdn.msaied.com/627/b162933f96fd615f3165bfe167816018.png) Laravel Boost AI Agents Context Engineering 

### Semantic Memory or Just Markdown? How Laravel Boost Manages AI Agent Project Rules

Laravel Boost tried semantic search with embeddings and a vector index to give AI coding agents project memory...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 3 Sep 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/semantic-memory-or-just-markdown-how-laravel-boost-manages-ai-agent-project-rules) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.msaied.com)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

Explore

- [Home](https://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [Contact](https://www.msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
