Introduction
An Active Record class needs more than basic CRUD. Query methods like where(), all(), and create() provide a fluent API for common operations, while relationship methods let objects navigate to their associated records.
Key Concepts
- Static Query Methods: Class-level methods like
User::where()that return collections of model instances. - Relationships: Methods on a model instance that load associated records (e.g.,
$user->orders()returns Order instances). - Mass Assignment: Setting multiple attributes at once from an array, guarded by
$fillable.
Real World Context
In a typical controller, you might write $user = User::find($id) to load a user, $user->orders() to get their orders, and User::where('status', 'active')->get() to list active users. These patterns make controllers concise and readable.
Deep Dive
Adding query methods to the base class:
php<?php abstract class ActiveRecord { // ... previous code ... public static function all(): array { $table = static::$table; $stmt = static::$pdo->query("SELECT * FROM {$table}"); return array_map([static::class, 'hydrate'], $stmt->fetchAll()); } public static function where( string $column, mixed $value, string $operator = '=' ): QueryBuilder { return (new QueryBuilder(static::$pdo, static::class)) ->table(static::$table) ->where($column, $value, $operator); } public static function create(array $attributes): static { $instance = new static(); foreach ($attributes as $key => $value) { $instance->$key = $value; // Goes through __set, checks $fillable } $instance->save(); return $instance; } public function fill(array $attributes): static { foreach ($attributes as $key => $value) { if (in_array($key, static::$fillable, true)) { $this->attributes[$key] = $value; } } return $this; } public function toArray(): array { return $this->attributes; } }
Concrete models define relationships as methods:
php<?php class User extends ActiveRecord { protected static string $table = 'users'; protected static array $fillable = ['name', 'email', 'password_hash']; public function orders(): array { return Order::where('user_id', $this->id)->get(); } public function getDisplayName(): string { return ucfirst($this->name); } } class Order extends ActiveRecord { protected static string $table = 'orders'; protected static array $fillable = ['user_id', 'status', 'total']; public function user(): ?User { return User::find($this->user_id); } public function items(): array { return OrderItem::where('order_id', $this->id)->get(); } }
Usage is clean and expressive:
php<?php ActiveRecord::setConnection($pdo); // Create $user = User::create([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); // Read $user = User::find(1); echo $user->name; // Query $activeUsers = User::where('status', 'active')->get(); $recentOrders = Order::where('created_at', date('Y-m-d'), '>=')->get(); // Update $user->name = 'Jane Doe'; $user->save(); // Delete $user->delete(); // Relationships $user = User::find(1); foreach ($user->orders() as $order) { echo "Order #{$order->id}: \${$order->total}\n"; }
In PHP 8.5, the #[\NoDiscard] attribute can mark methods whose return values should not be ignored:
php<?php class User extends ActiveRecord { #[\NoDiscard] public function save(): bool { return $this->exists ? $this->update() : $this->insert(); } } // PHP 8.5 will warn if you ignore the return value: $user->save(); // Warning: return value of save() is not used $success = $user->save(); // OK if (!$user->save()) { // OK // handle error }
This is especially useful for methods that return success/failure booleans.
Common Pitfalls
- Calling relationship methods in a loop —
$user->orders()inside a foreach over users creates an N+1 problem. Use eager loading for batch access. - Ignoring save() return values —
save()returns false on failure. Ignoring it means silent data loss. PHP 8.5's#[\NoDiscard]attribute helps catch this.
Best Practices
- Add the
#[\NoDiscard]attribute to save() and delete() — PHP 8.5's attribute warns developers when they ignore the success/failure return value. - Combine Active Record with eager loading — For list pages, use query builder methods with JOINs or IN clauses instead of calling relationship methods in loops.
Summary
- Static query methods (
find,where,create,all) provide a fluent API for model operations. - Relationship methods navigate between models but risk N+1 if called in loops.
- PHP 8.5's
#[\NoDiscard]attribute warns when critical return values likesave()are ignored. - Always guard mass assignment with
$fillableand avoid relationship methods inside loops.
Code Examples
php
<?php
declare(strict_types=1);
class User extends ActiveRecord {
protected static string $table = 'users';
protected static array $fillable = ['name', 'email', 'status'];
protected static array $hidden = ['password_hash'];
protected function validate(): bool {
$this->errors = [];
if (empty($this->attributes['name'])) {
$this->errors['name'] = 'Name is required';
}
if (empty($this->attributes['email'])) {
$this->errors['email'] = 'Email is required';
} elseif (!filter_var($this->attributes['email'], FILTER_VALIDATE_EMAIL)) {
$this->errors['email'] = 'Invalid email format';
}
return empty($this->errors);
}
protected function beforeCreate(): void {
$this->attributes['created_at'] = date('Y-m-d H:i:s');
}
#[\NoDiscard]
public function save(): bool {
if (!$this->validate()) { return false; }
$this->beforeCreate();
return parent::save();
}
public function toArray(): array {
return array_diff_key($this->attributes, array_flip(static::$hidden));
}
}
?>