Use when creating aggregate root entities, child entities, read projections, or adding domain behavior methods to Eloquent models in a DDD bounded context.
Every aggregate root is an Eloquent model enriched with domain behavior. Entities live in {Module}/Domain/, use a static new() constructor that registers a creation event, and expose mutation methods that register domain events before returning $this.
declare(strict_types=1);
namespace App\CustomerRelationshipManagement\Contacts\Domain;
use App\CustomerRelationshipManagement\Contacts\Domain\Events\ContactCreated;
use App\CustomerRelationshipManagement\Contacts\Domain\Events\ContactDeleted;
use App\Shared\Domain\HasDomainEvents;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $id
* @property string $first_name
* @property string $last_name
* @property string $email
* @property string $phone
*
* @method static ContactFactory factory()
*/
class Contact extends Model
{
use HasDomainEvents;
use HasFactory;
use HasUuids;
protected $table = 'crm_contacts';
protected $fillable = [
'first_name',
'last_name',
'email',
'phone',
];
public static function new(array $attributes = []): self
{
$contact = new self($attributes);
$contact->registerDomainEvent(new ContactCreated($contact));
return $contact;
}
public function toBeDeleted(): self
{
$this->registerDomainEvent(new ContactDeleted($this->id));
return $this;
}
protected function casts(): array
{
return [
'gender' => ContactGender::class,
];
}
protected static function newFactory(): ContactFactory
{
return new ContactFactory();
}
}
Key conventions visible in this template:
@property docblock lists all attributes with types — enables IDE completion@method static docblock declares the factory return type$table uses the bounded context prefix (crm_)casts() is a method (not $casts property) — current Laravel conventionnewFactory() points to the co-located factory class explicitlynew()Every aggregate root provides a static new() that replaces direct new Model() calls:
public static function new(array $attributes = []): self
{
$entity = new self($attributes);
$entity->registerDomainEvent(new EntityCreated($entity));
return $entity;
}
Rules:
newFactory()){Entity}Created domain eventself — never voidIndustry) may omit the event registrationVariants:
// Guard in constructor — throws before creating
public static function new(array $attributes, Collection $requiredRelation): self
{
if ($requiredRelation->isEmpty()) {
throw new NoMatchingItemException('At least one item is required.');
}
$entity = new self($attributes);
$entity->registerDomainEvent(new EntityCreated($entity));
return $entity;
}
// Second named constructor for a special creation path
public static function newAsServiceUser(array $attributes = []): self
{
$attributes['email_verified_at'] = now();
$attributes['password'] = bcrypt(Str::random(32));
$user = new self($attributes);
$user->registerDomainEvent(new UserCreated($user));
return $user;
}
Every aggregate root that raises events provides toBeDeleted():
public function toBeDeleted(): self
{
$this->registerDomainEvent(new EntityDeleted($this->id));
return $this;
}
Note: The event payload is the entity's $id (string), not the entity object — because the entity is about to be deleted. This differs from creation events which pass the full entity.
Mutation methods follow a consistent pattern — forceFill() + register event + return $this:
public function updateName(string $first, string $last): self
{
$this->forceFill([
'first_name' => $first,
'last_name' => $last,
]);
$this->registerDomainEvent(new ContactNameUpdated($this));
return $this;
}
Rules:
update{Attribute}() or {verb}() (e.g., verify(), activate(), expire())forceFill() for attribute mutation — avoids mass-assignment guard issues on internal operations$this for fluent chainingsave() — persistence is the use case's responsibilityState transitions (when using spatie/laravel-model-states):
public function activate(): self
{
try {
$this->status->transitionTo(Active::class);
} catch (CouldNotPerformTransition $e) {
throw new VisaException($e->getMessage());
}
$this->registerDomainEvent(new VisaActivated($this));
return $this;
}
Wrap Spatie's transitionTo() in a try/catch and convert CouldNotPerformTransition into a domain exception. See the implementing-value-objects skill for state machine setup.
Computed attributes — use Laravel's Attribute class for derived values:
protected function name(): Attribute
{
return Attribute::get(fn () => trim("{$this->first_name} {$this->last_name}"));
}
Lifecycle hooks — use booted() to set default values on model events:
protected static function booted(): void
{
static::creating(function (Visa $visa) {
if ($visa->subclass !== Subclass::Student) {
$visa->expire_at = now()->addYear();
}
});
}
Consult the Laravel documentation for full details on Attribute accessors and model lifecycle events (boot() / booted()).
Child entities live inside the parent module's Domain/ directory — never in their own module:
Companies/Domain/Company.php # Aggregate root
Companies/Domain/CompanyContact.php # Child entity
Child entity characteristics:
HasFactory + HasUuids (usually)HasDomainEvents — the parent aggregate manages eventsstatic new() — they are created through the aggregate root's methodsInvitation::forUser(string $email, string $name, string $role): self)Aggregate root manages child lifecycle:
// In Company.php (aggregate root)
public function addContact(array $attributes): self
{
if ($this->contacts()->count() >= 10) {
throw new CompanyException('Maximum 10 contacts allowed.');
}
$this->contacts()->create($attributes);
return $this;
}
A read projection is an Eloquent model that queries another aggregate's table through a different domain lens. It has no write behavior. Use this when a bounded context needs to read data owned by another context without importing its domain classes.
declare(strict_types=1);
namespace App\{ConsumerContext}\{Module}\Domain;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class {ProjectionName} extends Model
{
use HasUuids;
protected $table = '{owner_prefix}_{table}'; // Another aggregate's table
protected $hidden = [/* fields this context should not expose */];
protected static function booted(): void
{
static::addGlobalScope('{scope-name}', function ($query) {
$query->where(/* filter to relevant rows */);
});
}
}
Characteristics:
$table points to another aggregate's table (e.g., an Applicant projection in VisaManagement reading crm_contacts)$hidden set — exposes only what this context needsbooted() to filter to relevant rowsHasDomainEvents, no HasFactory, no static new()Immutable projection — when writes must be physically blocked:
protected static function booted(): void
{
static::addGlobalScope('read-only', fn ($q) => $q->where('type', 'system'));
static::saving(fn () => false);
static::creating(fn () => false);
static::updating(fn () => false);
static::deleting(fn () => false);
}
Domain validation rules are defined as a trait in {Module}/Domain/ and consumed by Infrastructure Request classes:
// Domain layer — defines the rules
declare(strict_types=1);
namespace App\CustomerRelationshipManagement\Contacts\Domain;
use Illuminate\Validation\Rule;
trait ValidationRules
{
public function rules(?string $contact = null): array
{
$unique = $contact
? Rule::unique(Contact::class)->ignore($contact)
: Rule::unique(Contact::class);
return [
'first_name' => ['required', 'string', 'max:64'],
'last_name' => ['required', 'string', 'max:64'],
'email' => ['required', 'string', 'email', 'max:255', $unique],
'gender' => [Rule::enum(ContactGender::class)],
];
}
}
Rules:
?string $contact = null) toggles the unique rule for create vs. updateRule::enum() to validate backed enumsrules() for create, updateRules() for updateValidationRules when one per module, {Entity}ValidationRules when multiple exist in the same contextnew PostCodeRule) wrap value object validation — see implementing-value-objectsThe Infrastructure layer consumes the trait in Form Request classes — see implementing-form-requests.
Aggregate roots compose behavior through traits from the Shared Kernel. Use as needed — don't apply all to every entity:
| Trait | Source | Purpose | Details |
|---|---|---|---|
HasDomainEvents | Shared/Domain/ | Event registration + publishing | See implementing-domain-events |
HasFactory | Eloquent | Test factory support | See implementing-factories |
HasUuids | Eloquent | UUID primary keys | Standard on all entities |
HasStates | spatie/laravel-model-states | State machine support | See implementing-value-objects |
HasProfilePhoto | Shared/Domain/ | Avatar management | Jetstream stack |
Notifiable | Eloquent | Mail/notification channels | Consult Laravel docs |
IsExtensible | Shared/Domain/ | Custom attribute support | Marker trait |
CanGenerateIdentifiers | Shared/Application/ | UUID generation in use cases | Application layer, not entities |
Note: CanGenerateIdentifiers lives in the Application layer — it is used by use case classes, not by entities directly.
| Mistake | Fix |
|---|---|
Calling save() inside a domain method | Domain methods mutate state; persistence is the use case's job |
| Passing entity object in deletion event | Pass $this->id (string) — the entity is about to be deleted |
| Child entity in its own module | Keep inside parent module's Domain/ directory |
Read projection with HasDomainEvents | Read projections are read-only — no events, no static new() |
Using $casts property instead of method | Use protected function casts(): array (method form) |
Missing @property docblock | Always declare all attributes with types for IDE support |
forceFill() without returning $this | All behavior methods return $this for fluent chaining |
Adding CanGenerateIdentifiers to entity | It's an Application-layer trait for use cases, not for models |
Model (or Authenticatable for User)?HasDomainEvents + HasFactory + HasUuids?$table uses bounded context prefix?static new() creates entity AND registers Created event?toBeDeleted() registers Deleted event with entity ID?forceFill() + register event + return $this?@property docblock lists all attributes?casts() is a method, not a property?newFactory() points to named factory class?HasDomainEvents and no static new()?$table override, global scope, and no write behavior?ValidationRules trait defined if entity has validation needs?This skill uses Laravel-specific features. Consult the official Laravel documentation (via Context7 or web search) for details on:
Illuminate\Database\Eloquent\Model — base Eloquent model, mass assignment, $fillable / $guardedHasFactory, HasUuids — Eloquent traits for factories and UUID primary keysNotifiable — notification channel support (mail, SMS, Slack)boot() / booted() — model lifecycle hooks and event callbacksforceFill() — bypass mass assignment guards for internal mutationsAttribute — computed/virtual accessors and mutatorsRule::unique(), Rule::enum() — validation rule builders