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

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

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

  [ home ](https://msaied.com)    [ articles ](https://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%2Fmsaied.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%2Fmsaied.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://msaied.com/articles) 

 [ ![What's Missing from Your PHP Development Environment: Meet DDLess](https://cdn.msaied.com/379/baa8990d4c7b46d18498d69d68f9b6d2.png) DDLess PHP Debugging Laravel Tools 

### What's Missing from Your PHP Development Environment: Meet DDLess

DDLess is a PHP development workbench that brings step debugging, an in-breakpoint playground, and an interact...

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

 6 Jul 2026     3 min read  

  Read    

 ](https://msaied.com/articles/whats-missing-from-your-php-development-environment-meet-ddless) [ ![CQRS in Laravel Without a Framework: Commands, Handlers, and Query Objects](https://cdn.msaied.com/376/bec9da4b7a7ddeee26dac3df6f5d6c44.png) laravel cqrs architecture 

### CQRS in Laravel Without a Framework: Commands, Handlers, and Query Objects

Skip the heavy CQRS libraries. Learn how to implement commands, command handlers, and query objects in plain L...

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

 6 Jul 2026     3 min read  

  Read    

 ](https://msaied.com/articles/cqrs-in-laravel-without-a-framework-commands-handlers-and-query-objects) [ ![Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding State from Events](https://cdn.msaied.com/375/d5f6b9ed0a38be33a23430a1637a06d5.png) laravel event-sourcing ddd 

### Event Sourcing in Laravel: Aggregates, Projectors, and Rebuilding State from Events

A practical walkthrough of event sourcing in Laravel — defining aggregates, writing projectors, and safely reb...

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

 6 Jul 2026     4 min read  

  Read    

 ](https://msaied.com/articles/event-sourcing-in-laravel-aggregates-projectors-and-rebuilding-state-from-events) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://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://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://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)
