PHPackages                             splitstack/laravel-domainable - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. splitstack/laravel-domainable

ActiveLibrary

splitstack/laravel-domainable
=============================

v0.1.0(yesterday)01↑2900%MITPHPPHP ^8.4CI passing

Since Aug 1Pushed todayCompare

[ Source](https://github.com/EmilienKopp/laravel-domainable)[ Packagist](https://packagist.org/packages/splitstack/laravel-domainable)[ RSS](/packages/splitstack-laravel-domainable/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (19)Versions (2)Used By (0)

Laravel Domainable
==================

[](#laravel-domainable)

[![Tests](https://github.com/EmilienKopp/laravel-domainable/actions/workflows/tests.yml/badge.svg)](https://github.com/EmilienKopp/laravel-domainable/actions/workflows/tests.yml)[![Coverage](https://raw.githubusercontent.com/EmilienKopp/laravel-domainable/main/.github/badges/coverage.svg)](https://github.com/EmilienKopp/laravel-domainable/actions/workflows/tests.yml)[![Latest Version](https://camo.githubusercontent.com/223fbc96f17918930ae2cefb1f48a9f015592b7c21679a4a4c42b6575a977f36/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f73706c6974737461636b2f6c61726176656c2d646f6d61696e61626c652e737667)](https://packagist.org/packages/splitstack/laravel-domainable)[![Total Downloads](https://camo.githubusercontent.com/5f421941c046cff614ab818a7e652fc2f3a3ff5b2828a2d6dd88d4681c9b1f43/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f73706c6974737461636b2f6c61726176656c2d646f6d61696e61626c652e737667)](https://packagist.org/packages/splitstack/laravel-domainable)[![PHP Version](https://camo.githubusercontent.com/6037271a1c1cefa46560ed6271cbadc3a1660a1b0985d44596b717b763404229/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f73706c6974737461636b2f6c61726176656c2d646f6d61696e61626c652e737667)](https://packagist.org/packages/splitstack/laravel-domainable)[![License](https://camo.githubusercontent.com/c09aabaa398a45a9a04457bc29340398b26285d75b82a6d9ef5a44cb1ad766a0/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f73706c6974737461636b2f6c61726176656c2d646f6d61696e61626c652e737667)](https://github.com/EmilienKopp/laravel-domainable/blob/main/LICENSE)

**Pragma &gt; Dogma.**

Adopt DDD principles in Laravel and Eloquent without the overhead of writing and maintaining a separate entity class for every model.

This package slightly bends the reality of the Dependency Inversion Principle to give a pragmatic edge to your codebase. You can still write your own aggregate root and entity classes when you want to; this is just a convenience for the common case of a single model per aggregate.

What you get
------------

[](#what-you-get)

Plain DDD in LaravelWith this packageClasses per aggregateModel plus a hand-written entityJust the modelModel ↔ entityMapping code you write and maintainRuntime view, no mappingDomain surfaceWhatever the entity happens to exposeAdditive, opt in per method with `#[Domain]`Infrastructure leaksLikely (`save`, `newQuery`, relations)Can't leak, never exposedInvariantsCalled by hand, easy to forgetRun automatically after every domain callThe core capabilities, in order:

- **Domainable** turns a model into a domain-facing **Entity**: read-only attributes plus the methods you opt into, nothing else.
- **Invariants** are rules that run automatically after every domain call, with policies that decide what a failure does (throw, quarantine, correct, ignore).
- **Repositories** hydrate entities out of the database and keep infrastructure on their side of the boundary.
- **Traits** (`Domainable`, `IsEntity`) let you reuse the same machinery on models or on plain custom aggregate roots.

Requirements
------------

[](#requirements)

- PHP 8.4+
- Laravel 10, 11, 12, or 13

Disclaimer
----------

[](#disclaimer)

This package is in early development (`v0.x`). The API is not stable yet, and breaking changes may be made without warning.

Installation
------------

[](#installation)

```
composer require splitstack/laravel-domainable
```

The service provider is auto-discovered.

What a Domainable is
--------------------

[](#what-a-domainable-is)

A **Domainable** is an Eloquent model that can hand back a domain **Entity**: a runtime view of itself that exposes only what the domain is allowed to touch.

The Entity is a proxy over the model. It gives you:

- **Attributes**, read-only, through `__get` (casts and accessors apply).
- **Methods marked `#[Domain]`**, forwarded to the model. Anything else (`save`, `newQuery`, relations, arbitrary setters) throws `BadMethodCallException`, so infrastructure can't leak into domain code.

You make a model domainable by adding the `Domainable` trait and the `ProvidesEntity` contract, marking the domain methods with `#[Domain]`, and declaring any invariants (covered in the next section).

```
use Illuminate\Database\Eloquent\Model;
use Splitstack\Domainable\Attributes\Domain;
use Splitstack\Domainable\Data\Invariant;
use Splitstack\Domainable\Contracts\ProvidesEntity;
use Splitstack\Domainable\Concerns\Domainable;

class Order extends Model implements ProvidesEntity
{
    use Domainable;

    protected $fillable = ['status', 'total'];

    protected $casts = ['total' => 'integer'];

    #[Domain]
    public function cancel(): void
    {
        $this->status = 'cancelled';
    }

    #[Domain]
    public function applyDiscount(int $amount): static
    {
        $this->total -= $amount;

        return $this;
    }

    protected function totalIsNonNegative(): Invariant
    {
        return Invariant::make(
            rule: fn () => $this->total >= 0,
            message: 'total below zero',
        );
    }

    // Regular Eloquent behavior lives here too, untouched.
    public function customer()
    {
        return $this->belongsTo(Customer::class);
    }
}
```

Call `asEntity()` to cross into the domain view:

```
$order = Order::find($id)->asEntity(); // an Entity, never the raw model

$order->total;             // read-only attribute access
$order->cancel();          // allowed: marked #[Domain]. Invariants run after.
$order->applyDiscount(50); // fluent: returns the entity, never the raw model

$order->save();            // 💥 BadMethodCallException: not domain behavior
$order->newQuery();        // 💥 BadMethodCallException: not domain behavior
```

A `#[Domain]` method that returns `$this` on the model gives you back the entity, so fluency works without ever leaking the model. Two more methods round out the surface:

ℹ️ Nothing prevents you from calling `#[Domain]` methods on the model itself or in repository/infra code, but we wouldn't recommend it. The point is that the entity mutates itself and the persistence is dumb.

- **`assertInvariants()`** runs every invariant on demand.
- **`toModel()`** hands the backing model back to the infrastructure layer.

Invariants
----------

[](#invariants)

An **invariant** is a rule that must hold for the entity to be valid. Invariants run automatically after every domain operation, and `asEntity()` asserts them before handing the entity back, so an entity can never escape into your domain in a state a strict invariant forbids.

An invariant is a method that takes no arguments and returns an `Invariant`value object built with `Invariant::make()`:

- `rule`: a closure returning `true` when the state is valid.
- `message`: the text surfaced when the rule fails.
- `touches`: optional attribute names to check the rule against. Without it, the rule takes no arguments and reads `$this` (`fn () => $this->total >= 0`). With it, the rule receives each named attribute's value and must hold for all of them (`fn ($value) => $value >= 0`).
- `policy`: a `HydrationPolicy`, default `Strict` (see below).
- `default`: replacement value used by the `AutoCorrect` policy.

A broken invariant under the default policy surfaces as an `InvariantViolationException` carrying the method name as its label:

```
$order->applyDiscount(999999);
// InvariantViolationException: Invariant [totalIsNonNegative] violated: total below zero
```

### Hydration policies

[](#hydration-policies)

`policy:` decides what a failed invariant does. `Lenient` and `AutoCorrect`require `touches`; `AutoCorrect` also requires a `default`.

PolicyOn failure`Strict` (default)Throws `InvariantViolationException`.`Quarantine`Flags the entity (`isQuarantined()`) instead of throwing.`Lenient`Accepts the value, no throw.`AutoCorrect`Writes `default` into the touched attributes.```
use Splitstack\Domainable\Enums\HydrationPolicy;

return Invariant::make(
    rule: fn ($value) => $value >= 0,
    message: 'total below zero',
    touches: ['total'],
    policy: HydrationPolicy::Quarantine,
);
```

The `Quarantine` policy is what makes an entity load in an invalid state without throwing. `isQuarantined()` reports it, and the repository class decides how to treat quarantined entities (see [below](#quarantined-entities)).

### Invariant props

[](#invariant-props)

#### `$touches`

[](#touches)

Allows to apply the predicate callback to a certain set of attributes.

⚠️ It is required for `Lenient` and `AutoCorrect` policies.

#### `$default`

[](#default)

The replacement value used by the `AutoCorrect` policy.

Repositories
------------

[](#repositories)

A **repository** is the hydrate direction: it pulls entities out of the database so the rest of your domain code never sees a raw model. Its `find()` and `all()`return entities, not models.

Declare one by naming the model it is for:

```
use Splitstack\Domainable\Repository\BaseRepository;

class OrderRepository extends BaseRepository
{
    protected string $for = Order::class;
}
```

Then work through the entities it hands back:

```
$orders = new OrderRepository();

$order = $orders->find($id); // an Entity, never the raw model
$order->cancel();

$all = $orders->all();       // a collection of entities
```

### Quarantined entities

[](#quarantined-entities)

A **quarantined entity** is one that hydrated in an invalid state (a `Quarantine`-policy invariant failed) but that you still want to load, flag, and handle rather than reject outright. The repository decides how to treat them:

```
$orders->all();                              // excludes quarantined entities
$orders->withQuarantined();                  // includes them
$orders->find($id);                          // returns the entity even if quarantined (⚠️if your data is corrupted and does not meet the invariant, this will throw InvariantViolationException)
$orders->find($id, nullIfQuarantined: true); // null if quarantined
$orders->find($id, fetchUnsafe: true);       // returns the entity without checking invariants

$orderEntity->isQuarantined();                     // true when a Quarantine-policy invariant failed
```

`fetchUnsafe: true` is the escape hatch for loading an entity you already know might be invalid, so you can inspect or repair it instead of having `find()` throw. It only skips the check on hydration.

Any later `#[Domain]` call on that entity **still asserts**, so a broken strict invariant surfaces the moment you try to act on it. Unlike quarantine, it does not flag the entity (`isQuarantined()` stays `false`) and it applies per fetch rather than changing an invariant's policy everywhere.

#### Saving entities

[](#saving-entities)

To persist changes, only call `save()` on the repository, not on the entity.

```
$orderEntity->save();         // 💥 BadMethodCallException: not domain behavior
$orders->save($orderEntity);  // ✅ allowed: repository handles persistence
```

Using the traits in custom entities
-----------------------------------

[](#using-the-traits-in-custom-entities)

The invariant machinery is split out of `Domainable` into its own `IsEntity`trait (which `Domainable` uses). Attach `IsEntity` and the `EnforcesInvariants`contract to any class, no Eloquent needed, to reuse invariants on a custom aggregate root and call `assertInvariants()` yourself.

```
use Splitstack\Domainable\Concerns\IsEntity;
use Splitstack\Domainable\Contracts\EnforcesInvariants;

class MyAggregateRootOrEntity implements EnforcesInvariants
{
    use IsEntity;

    // ... methods returning Invariant, checked by assertInvariants()
}
```

Type safety
-----------

[](#type-safety)

The entity is a magic-method proxy, so out of the box your editor and static analyzer see a generic `Entity`. Two generators buy the type safety back. Both read the same `#[Domain]` methods and attributes you already declared.

### Typed domain interface (strictest)

[](#typed-domain-interface-strictest)

Generate an interface that lists the domain methods and attribute types. Have the model implement it, so static analysis checks the methods really exist, and type `asEntity()` to return it so consumers get full autocomplete.

```
php artisan entity:interface "App\Models\Order"           # print
php artisan entity:interface "App\Models\Order" --write   # write OrderEntity.php beside the model
```

```
namespace App\Models;

/**
 * Domain entity contract for \App\Models\Order.
 *
 * @property-read int    $total
 * @property-read string $status
 */
interface OrderEntity
{
    public function cancel(): void;

    public function applyDiscount(int $amount): self;
}
```

Options: `--namespace=`, `--suffix=` (defaults to `Entity`), `--path=`, `--write`.

### In-place model annotations (ide-helper style)

[](#in-place-model-annotations-ide-helper-style)

Add a marker-tagged docblock above the model with `@property-read` lines and a typed `@method asEntity()`. No new type to wire. Re-running replaces the block instead of duplicating it.

```
php artisan entity:annotations "App\Models\Order"                                    # print docblock
php artisan entity:annotations "App\Models\Order" --entity="App\Models\OrderEntity" --write
```

Pass `--entity=` to point `asEntity()` at a generated interface. Without `--write` the docblock is printed so you can review it first.

Read-side projections
---------------------

[](#read-side-projections)

`Splitstack\Domainable\EntityModel` is a separate base for the read side: an Eloquent model you can query but never persist. It is not the domain entity. Use it when you want a cheap, immutable result object that nobody should mutate or save.

Testing
-------

[](#testing)

Domain behavior needs no database. An Eloquent model works in memory, so build one and exercise it directly:

```
$order = Order::factory()->make(['total' => 100]); // or new Order([...])

$order->asEntity()->applyDiscount(50);

expect(fn () => Order::factory()->make(['total' => -1])->asEntity())
    ->toThrow(InvariantViolationException::class);
```

Only repository tests (find, all) need a real or in-memory sqlite database, since they cross the persistence boundary.

License
-------

[](#license)

MIT

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance100

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity40

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

1d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/b9e77e55bb8341a1fa99f8348597070b4de230b5fc457e866e4f196dd56843f1?d=identicon)[EmilienKopp](/maintainers/EmilienKopp)

---

Top Contributors

[![EmilienKopp](https://avatars.githubusercontent.com/u/91975560?v=4)](https://github.com/EmilienKopp "EmilienKopp (12 commits)")

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/splitstack-laravel-domainable/health.svg)

```
[![Health](https://phpackages.com/badges/splitstack-laravel-domainable/health.svg)](https://phpackages.com/packages/splitstack-laravel-domainable)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3355.4M352](/packages/psalm-plugin-laravel)[illuminate/queue

The Illuminate Queue package.

20433.0M1.7k](/packages/illuminate-queue)[spatie/laravel-medialibrary

Associate files with Eloquent models

6.2k45.4M679](/packages/spatie-laravel-medialibrary)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

255.2k](/packages/aedart-athenaeum)[flarum/core

Delightfully simple forum software.

261.5M2.4k](/packages/flarum-core)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9872.4M142](/packages/roots-acorn)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
