The two systems don’t conflict. But they don’t overlap the way you’d expect either. The rule is simpler than the confusion suggests: hooks for PHP classes, accessors for Eloquent attributes.
Day 22 of this series covered PHP 8.4 property hooks in full. Day 20 covered PHP Attributes on Eloquent models. Both ended with the same question hanging in the air: how do these two things interact inside an Eloquent model?
The answer requires understanding one thing about how Eloquent works internally — and once you understand it, the rules become clear and permanent.
The Central Fact: Two Different Access Systems
Eloquent models are not plain PHP objects. Under the hood, every time you read or write a model attribute — $user->name, $user->email, $invoice->amount — Eloquent intercepts that access through PHP’s __get() and __set() magic methods.
This is how Eloquent works:
- Reads (
$user->name) →Model::__get()→ looks in$attributesarray → runs any defined accessor → returns value - Writes (
$user->name = 'Alice') →Model::__set()→ runs any defined mutator → stores in$attributesarray
PHP 8.4 property hooks operate at the language level, below the magic method layer. A hooked property on a class intercepts access via PHP’s runtime, not via __get()/__set().
When you define a PHP property hook on an Eloquent model, there is a conflict in jurisdiction:
class User extends Model
{
// ⚠️ This creates a real PHP property — not an Eloquent attribute
public string $name {
get => ucwords($this->name);
set => $this->name = strtolower($value);
}
}
If you declare public string $name with a hook, PHP creates a real class property called $name. Eloquent’s __get()/__set() are only called for properties that don’t exist on the class. Now that $name is a real declared property, Eloquent’s magic never fires for it. The value is stored directly in the PHP property — bypassing the $attributes array entirely.
That means:
$user->save()does not persist it (Eloquent reads from$attributes, not from your hook)$user->toArray()does not include it$user->fresh()does not populate it- Mass assignment, dirty tracking, casting, events — all skip it
This is not a bug and not a conflict to work around. It’s a fundamental boundary. PHP property hooks and Eloquent attributes are two separate systems with separate storage.
What You Should Actually Use for Eloquent Attributes
For anything that lives in the database and goes through Eloquent’s pipeline, use Eloquent accessors and mutators — the Attribute cast class introduced in Laravel 9:
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
// ✅ Eloquent accessor + mutator — goes through the attributes pipeline
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) => strtolower($value),
);
}
}
$user = User::find(1);
$user->name = 'ALICE SMITH'; // mutator fires → stored as 'alice smith'
echo $user->name; // accessor fires → 'Alice Smith'
$user->save(); // ✅ persists correctly
$user->toArray(); // ✅ includes formatted name
This is the right tool for database-backed attributes. It always has been.
Where Property Hooks Do Belong in a Laravel Model
Property hooks are not useless in Eloquent models. They’re just scoped to a different job: non-persisted class-level properties.
1. Computed view helpers (not persisted)
If you want a property that’s purely presentational — no database column, no cast, no inclusion in toArray() — a virtual property hook is clean:
class Invoice extends Model
{
// ✅ Virtual — no DB column, not in toArray(), not persisted
// Used only for display in Blade / Livewire components
public string $statusBadgeClass {
get => match($this->attributes['status']) {
'paid' => 'badge-green',
'overdue' => 'badge-red',
default => 'badge-grey',
};
}
public string $formattedAmount {
get => '$' . number_format($this->attributes['amount'] / 100, 2);
}
}
Note the key detail: read from $this->attributes['key'] directly, not $this->amount. Reading $this->amount would trigger Eloquent’s __get(), which works fine — but reading $this->attributes['amount'] is more explicit and avoids any potential recursion with computed properties.
2. Injected collaborator properties
Some patterns inject dependencies (services, repositories, policy objects) directly onto a model instance for use in methods. Property hooks can control that injection:
class User extends Model
{
// ✅ Non-persisted — injected at runtime, not stored in DB
public private(set) NotificationService $notifier {
set(NotificationService $service) {
$this->notifier = $service;
}
}
public function notify(string $message): void
{
$this->notifier->send($this, $message);
}
}
// Usage
$user = User::find(1);
$user->notifier = app(NotificationService::class);
$user->notify('Your invoice has been paid.');
Asymmetric visibility (public private(set)) ensures external code can inject it but not overwrite it arbitrarily after injection.
3. Validated constructor arguments on plain-PHP model companions
This is perhaps the sweetest spot: the DTO or data object that accompanies your Eloquent model. Not the model itself, but the value object you use to pass data into it:
// Not an Eloquent model — a plain PHP class
class CreateInvoiceData
{
public string $clientName {
set(string $value) {
if (strlen(trim($value)) < 2) {
throw new InvalidArgumentException('Client name too short.');
}
$this->clientName = trim($value);
}
}
public int $amountInPence {
set(int $value) {
if ($value <= 0) {
throw new InvalidArgumentException('Amount must be positive.');
}
$this->amountInPence = $value;
}
}
// Virtual — no backing storage
public string $formattedAmount {
get => '$' . number_format($this->amountInPence / 100, 2);
}
public function __construct(string $clientName, int $amountInPence)
{
$this->clientName = $clientName; // triggers set hook
$this->amountInPence = $amountInPence; // triggers set hook
}
}
// In your action/service class
class CreateInvoice
{
public function execute(CreateInvoiceData $data): Invoice
{
return Invoice::create([
'client_name' => $data->clientName,
'amount_in_pence' => $data->amountInPence,
]);
}
}
The DTO validates its own data using property hooks. The Eloquent model stays clean and focused on persistence.
The Complete Decision Map
Here’s how to decide which tool to reach for:
| Situation | Right tool |
|---|---|
| Format a DB column on read (e.g., ucwords name) | Attribute::make(get: ...) accessor |
| Transform a DB column on write (e.g., hash password) | Attribute::make(set: ...) mutator |
| Virtual property that combines two DB columns | Attribute::make(get: ...) — appears in toArray() |
| View helper (badge class, formatted price) — not in toArray() | PHP property hook (virtual, reads $this->attributes[...]) |
| Type-safe injected service on model instance | PHP property hook + private(set) |
| Validated input DTO used to populate model | PHP property hooks on plain PHP class, not on model |
| Non-DB config property on model | PHP property hook |
Practical: The Invoice Model with Both Systems
Here’s a real-world Eloquent model that uses the right tool for each job:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
#[Table('invoices')]
#[Fillable('client_name', 'amount_in_pence', 'status', 'due_date')]
class Invoice extends Model
{
protected $casts = [
'due_date' => 'datetime',
];
// ✅ Eloquent accessor — persisted column, goes through pipeline
protected function clientName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) => strtolower(trim($value)),
);
}
// ✅ Eloquent virtual accessor — computed from columns, in toArray()
protected function formattedAmount(): Attribute
{
return Attribute::make(
get: fn () => '$' . number_format($this->attributes['amount_in_pence'] / 100, 2),
);
}
// ✅ PHP property hook — view helper, NOT in toArray(), not persisted
public string $statusBadgeClass {
get => match($this->attributes['status'] ?? 'draft') {
'paid' => 'bg-green-100 text-green-800',
'overdue' => 'bg-red-100 text-red-800',
'sent' => 'bg-blue-100 text-blue-800',
default => 'bg-gray-100 text-gray-800',
};
}
// ✅ PHP property hook — non-persisted runtime flag
public bool $sendEmailOnSave = false {
set(bool $value) {
$this->sendEmailOnSave = $value;
}
}
}
// In a Livewire component or controller
$invoice = Invoice::find(1);
// Eloquent accessor — from pipeline, in toArray()
echo $invoice->formatted_amount; // '$2,400.00'
echo $invoice->client_name; // 'Acme Corp' (ucwords)
// PHP property hook — view helper only
echo $invoice->statusBadgeClass; // 'bg-green-100 text-green-800'
// PHP property hook — runtime flag, not persisted
$invoice->sendEmailOnSave = true;
$invoice->save(); // saves the DB columns; sendEmailOnSave is runtime-only
The PHP Attributes + Hooks Combination
Since Day 20 introduced the nine PHP attributes for Eloquent model configuration, it’s worth showing what a fully-modernised Laravel 13 model looks like combining all three systems:
#[Table('invoices')]
#[Fillable('client_name', 'amount_in_pence', 'status', 'due_date', 'user_id')]
#[Hidden('internal_notes')]
#[Connection('billing')]
class Invoice extends Model
{
// $casts stays as a property — no #[Cast] attribute yet in L13
protected $casts = [
'due_date' => 'datetime',
];
// Eloquent accessors (Attribute cast) — for DB-backed columns
protected function clientName(): Attribute { … }
protected function formattedAmount(): Attribute { … }
// PHP property hooks — for non-persisted view/runtime helpers
public string $statusBadgeClass { get => … }
public bool $sendEmailOnSave = false { set => … }
}
Three layers, each doing its job:
- PHP Attributes (
#[Table],#[Fillable]) — model metadata as static declaration Attribute::make()— DB column transformations through Eloquent’s pipeline- PHP property hooks — non-persisted helpers and runtime properties
The One-Line Summary
Property hooks live in PHP’s type system. Eloquent accessors live in Eloquent’s attribute pipeline. They don’t compete — they have different jobs. Hooks for non-persisted, non-pipeline class properties. Accessors for everything that touches the database.
Once you have that distinction, you never have to think about it again.
Follow for daily deep-dives on Laravel, PHP, Vue.js, and AI integrations. New article every day.
