⚙️Model API Reference
The Model layer provides an ORM-like interface for database operations with relationships, scopes, and soft deletes.
Overview#
The Model layer provides an ORM-like interface for database operations with relationships, scopes, and soft deletes.
Basic Usage#
Define a Model#
<?php
namespace App\Models;
use Siro\Core\Model;
final class User extends Model
{
protected string $table = 'users';
protected array $fillable = ['name', 'email', 'password'];
protected array $hidden = ['password'];
protected array $casts = [
'id' => 'int',
'status' => 'int',
'email_verified_at' => 'datetime',
];
}
CRUD Operations#
// Create
$user = User::create([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => Hash::make('secret'),
]);
// Find by ID
$user = User::find(1);
// Find or fail
$user = User::findOrFail(1); // Throws exception if not found
// Update
$user->update(['name' => 'Jane Doe']);
// Delete
$user->delete();
// All records
$users = User::all();
// Count
$count = User::count();
Query Builder Integration#
Models inherit all QueryBuilder methods:
// Where clauses
$users = User::where('status', 'active')->get();
$users = User::where('age', '>=', 18)->where('status', 'active')->get();
// Ordering
$users = User::orderBy('created_at', 'desc')->get();
// Limiting
$users = User::limit(10)->get();
// Select specific columns
$users = User::select(['id', 'name', 'email'])->get();
// Pagination
$result = User::paginate(20, $page);
// Returns: ['data' => [...], 'meta' => ['page' => 1, 'per_page' => 20, ...]]
// First record
$user = User::where('email', 'john@example.com')->first();
// Pluck single column
$emails = User::pluck('email');
// Chunk large datasets
User::chunk(100, function ($users) {
foreach ($users as $user) {
// Process each user
}
});
Relationships#
HasOne (One-to-One)#
// User model
public function profile(): HasOne
{
return $this->hasOne(Profile::class, 'user_id', 'id');
}
// Usage
$user = User::find(1);
$profile = $user->profile; // Profile instance or null
HasMany (One-to-Many)#
// User model
public function posts(): HasMany
{
return $this->hasMany(Post::class, 'user_id', 'id');
}
// Usage
$user = User::find(1);
$posts = $user->posts; // Collection of posts
// Query relationship
$publishedPosts = $user->posts()->where('status', 'published')->get();
BelongsTo (Many-to-One)#
// Post model
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
// Usage
$post = Post::find(1);
$author = $post->author; // User instance
BelongsToMany (Many-to-Many)#
// Post model
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'post_tag', 'post_id', 'tag_id');
}
// Usage
$post = Post::find(1);
$tags = $post->tags; // Collection of tags
// Attach relationship
$post->tags()->attach($tagId);
$post->tags()->attach([$tagId1, $tagId2]);
// Detach relationship
$post->tags()->detach($tagId);
$post->tags()->detach([$tagId1, $tagId2]);
// Sync (replace all)
$post->tags()->sync([$tagId1, $tagId2]);
// Toggle
$post->tags()->toggle($tagId);
// Check if has relationship
if ($post->tags()->has($tagId)) {
// Post has this tag
}
Retrieve extra pivot columns using withPivot():
// Define relationship with pivot columns
public function orders(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_product')
->withPivot(['quantity', 'price']);
}
// Pivot columns are included in query results
$order->products; // Each product has quantity and price from pivot
// Attach with pivot data
$user->roles()->attach($roleId, ['assigned_by' => $userId]);
// Sync with pivot data (associative array)
$user->roles()->sync([
$roleId1 => ['assigned_by' => $userId],
$roleId2 => ['assigned_by' => $userId],
]);
MorphMany (Polymorphic One-to-Many)#
One model can belong to multiple other models on a single association.
// Comment belongs to Post OR Product OR Article
// Table: comments (id, body, commentable_type, commentable_id)
class Comment extends Model
{
// Inverse polymorphic: which model owns this comment?
public function commentable(): MorphTo
{
return $this->morphTo('commentable');
}
}
class Post extends Model
{
// A post has many comments
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
class Product extends Model
{
// A product also has many comments
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
// Usage: get comments on any model
$post->comments; // All comments on this post
$product->comments; // All comments on this product
// Usage: get the parent of a comment
$comment->commentable; // Returns Post or Product or Article
// Create via polymorphic relationship
$post->comments()->create(['body' => 'Great post!']);
$product->comments()->create(['body' => 'Nice product!']);
MorphTo (Inverse Polymorphic)#
Defined automatically by morphTo() inside the child model:
$comment = Comment::find(1);
$owner = $comment->commentable; // Post, Product, or Article
Eager loading works for both directions:
// Eager load morphMany
$post = Post::with('comments')->find(1);
// Eager load morphTo (inverse)
$comment = Comment::with('commentable')->find(1);
Eager Loading#
Prevent N+1 query problems:
// ❌ Bad - N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Query per post!
}
// ✅ Good - 2 queries total
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // No additional queries
}
// Multiple relationships
$posts = Post::with('author', 'comments', 'tags')->get();
// Nested relationships
$posts = Post::with('author.profile', 'comments.user')->get();
// Conditional eager loading
$posts = Post::with(['comments' => function ($query) {
$query->where('status', 'approved')->orderBy('created_at', 'desc');
}])->get();
Soft Deletes#
Enable soft deletes to keep deleted records in database:
<?php
namespace App\Models;
use Siro\Core\Model;
use Siro\Core\DB\SoftDeletes;
final class Post extends Model
{
use SoftDeletes;
protected string $table = 'posts';
}
Usage:
// Soft delete (sets deleted_at timestamp)
$post->delete();
// Query automatically excludes soft-deleted records
Post::all(); // Only non-deleted posts
// Include soft-deleted in query
Post::withTrashed()->get();
// Get only soft-deleted records
Post::onlyTrashed()->get();
// Restore a soft-deleted record
Post::withTrashed()->find(1)->restore();
// Permanently delete from database
$post->forceDelete();
// Check if record is soft-deleted
if ($post->trashed()) {
echo "This post was deleted";
}
Migration for soft deletes:
Schema::table('posts', function (Blueprint $table) {
$table->softDeletes(); // Adds deleted_at column
});
Attribute Casting#
Automatically cast attributes to native types:
protected array $casts = [
'id' => 'int',
'price' => 'float',
'is_active' => 'bool',
'metadata' => 'array',
'published_at' => 'datetime',
'options' => 'json',
];
// Usage
$user = User::find(1);
$user->is_active; // true (boolean, not string "1")
$user->metadata; // ['key' => 'value'] (array, not JSON string)
$user->published_at; // DateTime instance
Available cast types:
int,integerreal,float,doublestringbool,booleanarrayjsonobjectdatetime,datetimestamp
Accessors & Mutators (v0.35.0+)#
Transform attributes automatically when getting or setting values.
Accessors#
Accessors are called when you retrieve an attribute. Define a method with the pattern get{AttributeName}Attribute:
class User extends Model
{
// Accessor - automatically called when accessing $user->name
public function getNameAttribute(mixed $value): string
{
return ucfirst(strtolower((string) $value));
}
// Virtual accessor (no database column)
public function getFullNameAttribute(): string
{
return "{$this->first_name} {$this->last_name}";
}
}
$user = User::find(1);
echo $user->name; // Auto-formatted: "John Doe"
echo $user->full_name; // Virtual: "John Doe"
Mutators#
Mutators are called when you set an attribute. Define a method with the pattern set{AttributeName}Attribute:
class User extends Model
{
// Mutator - automatically called when setting $user->email
public function setEmailAttribute(string $value): void
{
// IMPORTANT: Set directly to avoid infinite recursion
$reflection = new \ReflectionClass($this);
$property = $reflection->getProperty('attributes');
$property->setAccessible(true);
$attrs = $property->getValue($this);
$attrs['email'] = strtolower($value);
$property->setValue($this, $attrs);
}
// Hash password automatically
public function setPasswordAttribute(string $value): void
{
$reflection = new \ReflectionClass($this);
$property = $reflection->getProperty('attributes');
$property->setAccessible(true);
$attrs = $property->getValue($this);
$attrs['password'] = password_hash($value, PASSWORD_BCRYPT);
$property->setValue($this, $attrs);
}
}
$user = new User();
$user->email = 'TEST@EXAMPLE.COM'; // Stored as 'test@example.com'
$user->password = 'secret123'; // Stored as hashed value
⚠️ Important: Mutators must NOT call $this->setAttribute() inside the mutator method, as this causes infinite recursion. Use reflection or a helper trait to set attributes directly.
Helper Trait for Cleaner Mutators#
Create a reusable trait to simplify mutator syntax:
// app/Traits/MutatorHelper.php
trait MutatorHelper
{
protected function setRawAttribute(string $key, mixed $value): void
{
$reflection = new \ReflectionClass($this);
$property = $reflection->getProperty('attributes');
$property->setAccessible(true);
$attrs = $property->getValue($this);
$attrs[$key] = $value;
$property->setValue($this, $attrs);
}
}
// Usage in Model
class User extends Model
{
use MutatorHelper;
public function setEmailAttribute(string $value): void
{
$this->setRawAttribute('email', strtolower($value));
}
}
Virtual Attributes with Appends (v0.35.0+)#
Add computed/virtual attributes to JSON and array serialization using the $appends property:
class User extends Model
{
protected array $appends = ['full_name', 'initials', 'age'];
// These accessors will be included in toArray() and json_encode()
public function getFullNameAttribute(): string
{
return ($this->first_name ?? '') . ' ' . ($this->last_name ?? '');
}
public function getInitialsAttribute(): string
{
$first = $this->first_name ?? '';
$last = $this->last_name ?? '';
return strtoupper(substr($first, 0, 1) . substr($last, 0, 1));
}
public function getAgeAttribute(): int
{
return date_diff(date_create($this->birth_date), date_create('now'))->y;
}
}
$user = User::find(1);
$data = $user->toArray();
// Includes: id, first_name, last_name, birth_date, full_name, initials, age
$json = json_encode($user);
// {"id":1,"first_name":"John","last_name":"Doe","full_name":"John Doe","initials":"JD","age":30}
Note: Appended attributes respect the $hidden property. If an appended attribute is in $hidden, it will not be included.
class User extends Model
{
protected array $hidden = ['secret_data'];
protected array $appends = ['secret_data']; // Will NOT appear in output
}
DateTime Auto-Formatting (v0.35.0+)#
DateTime casts now automatically format to strings for JSON-safe serialization. This eliminates common JSON encoding errors with DateTime objects.
class Post extends Model
{
protected array $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
'published_at' => 'date',
];
}
$post = Post::find(1);
// Before v0.28: Returns DateTime object (causes JSON errors)
// After v0.28: Returns formatted string
echo $post->created_at; // "2024-01-15 10:30:00"
echo $post->published_at; // "2024-01-15 00:00:00"
// JSON serialization works perfectly - no more errors!
$json = json_encode($post);
// {"id":1,"created_at":"2024-01-15 10:30:00","published_at":"2024-01-15 00:00:00"}
// In API responses
return Response::success($post); // DateTime fields are strings, not objects
Supported date cast types:
'datetime'- Full datetime with time:Y-m-d H:i:s'date'- Date only:Y-m-d H:i:s
Scopes#
Reusable query constraints:
// Scope method (prefix with "scope")
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopePopular($query)
{
return $query->where('views', '>', 1000);
}
// Usage
$users = User::active()->get();
$posts = Post::popular()->orderBy('created_at', 'desc')->get();
// Dynamic scopes
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
// Usage
$posts = Post::ofType('article')->get();
Mass Assignment Protection#
Models require explicit $fillable declaration:
final class User extends Model
{
// Only these fields can be mass-assigned
protected array $fillable = ['name', 'email'];
// These are protected automatically:
// - password
// - role
// - is_admin
}
// ❌ Will trigger warning and block unauthorized fields
User::create($request->all());
// ✅ Explicitly allow only safe fields
User::create($request->only(['name', 'email']));
Runtime Warning: If $fillable is empty, framework triggers E_USER_WARNING:
Mass assignment protection: $fillable is empty on User model.
No fields will be mass-assigned. Define $fillable array.
Hidden Attributes#
Hide sensitive attributes from JSON responses:
protected array $hidden = ['password', 'remember_token'];
// Usage
return Response::json($user);
// Output: {"id":1,"name":"John","email":"john@example.com"}
// Password is NOT included
Temporarily show hidden attributes:
return Response::json($user->makeVisible(['password']));
Timestamps#
Models automatically manage created_at and updated_at:
// Disable timestamps
public bool $timestamps = false;
// Custom timestamp columns
const CREATED_AT = 'creation_date';
const UPDATED_AT = 'last_update';
Events#
Models fire lifecycle events automatically:
// Listen to events
Event::on('users.creating', function ($user): bool {
// Validate before create
if (User::where('email', $user->email)->exists()) {
return false; // Cancel creation
}
return true;
});
Event::on('users.created', function ($user) {
// Send welcome email
Mail::to($user->email)
->subject('Welcome!')
->html('<h1>Welcome!</h1>')
->queue();
});
Model lifecycle events:
saving → creating → INSERT → created → saved
saving → updating → UPDATE → updated → saved
deleting → DELETE → deleted
Best Practices#
1. Always Use Eager Loading#
// ❌ Bad
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
// ✅ Good
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name;
}
2. Use Fillable Protection#
// ❌ Dangerous
protected array $fillable = []; // Allows all fields
// ✅ Safe
protected array $fillable = ['name', 'email'];
3. Hide Sensitive Data#
protected array $hidden = ['password', 'token', 'secret'];
4. Cast Attributes Properly#
protected array $casts = [
'is_active' => 'bool',
'metadata' => 'array',
'published_at' => 'datetime',
];
5. Use Scopes for Reusable Queries#
// Define scope
public function scopePublished($query)
{
return $query->where('status', 'published');
}
// Use scope
$posts = Post::published()->orderBy('created_at', 'desc')->get();
6. Add Database Indexes#
Schema::table('users', function (Blueprint $table) {
$table->index('email'); // Speed up WHERE email = ?
$table->index(['status', 'created_at']); // Composite index
});
Examples#
Blog Post Model#
<?php
namespace App\Models;
use Siro\Core\Model;
use Siro\Core\DB\SoftDeletes;
final class Post extends Model
{
use SoftDeletes;
protected string $table = 'posts';
protected array $fillable = [
'title',
'slug',
'content',
'status',
'user_id',
];
protected array $casts = [
'id' => 'int',
'user_id' => 'int',
'published_at' => 'datetime',
];
protected array $hidden = [];
// Relationships
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class, 'post_id', 'id');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'post_tag', 'post_id', 'tag_id');
}
// Scopes
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopePopular($query)
{
return $query->where('views', '>', 1000);
}
// Accessors
public function getExcerptAttribute(): string
{
return substr($this->content, 0, 200) . '...';
}
}
Usage:
// Get published posts with author and tags
$posts = Post::published()
->with('author', 'tags')
->orderBy('published_at', 'desc')
->paginate(20);
// Create new post
$post = Post::create([
'title' => 'My First Post',
'slug' => 'my-first-post',
'content' => 'Content here...',
'status' => 'draft',
'user_id' => $request->user()['id'],
]);
// Publish post
$post->update(['status' => 'published', 'published_at' => date('Y-m-d H:i:s')]);
// Add tags
$post->tags()->attach([1, 2, 3]);
Troubleshooting#
Problem: "Mass assignment protection" warning#
Solution: Define $fillable array in your model:
protected array $fillable = ['field1', 'field2'];
Problem: Relationship returns null#
Check:
- Foreign key column exists in database
- Related record exists
- Relationship method name is correct
- Use eager loading:
Model::with('relation')->get()
Problem: N+1 queries#
Solution: Use eager loading:
// Instead of
$posts = Post::all();
// Use
$posts = Post::with('author', 'comments')->get();
See Also#
- Database Guide - Query builder reference
- SoftDeletes API - Soft delete support
- Pagination API - Pagination methods