Laravel API Resources: Transforming Your Eloquent Models for JSON Responses
Create consistent API responses with API resources.
Eloquent Models are the heart of Laravel's database interactions. Each model represents a single database table and provides an intuitive way to interact with that table's data. Think of models as intelligent representatives of your database tables that understand relationships, business logic, and data validation.
# Create a model with migration
php artisan make:model Post -m
# Create model with migration and controller
php artisan make:model Post -mc
# Create model with migration, controller, and factory
php artisan make:model Post -mcf
# Create model in specific directory
php artisan make:model Models/Post
<?php
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Post extends Model
{
use HasFactory;
/**
* The table associated with the model.
*/
protected $table = 'posts';
/**
* The primary key for the model.
*/
protected $primaryKey = 'id';
/**
* Indicates if the IDs are auto-incrementing.
*/
public $incrementing = true;
/**
* The data type of the auto-incrementing ID.
*/
protected $keyType = 'int';
/**
* Indicates if the model should be timestamped.
*/
public $timestamps = true;
/**
* The attributes that are mass assignable.
*/
protected $fillable = [
'title', 'content', 'user_id', 'category_id'
];
/**
* The attributes that should be hidden for arrays.
*/
protected $hidden = [
'password', 'remember_token',
];
/**
* The attributes that should be cast.
*/
protected $casts = [
'is_published' => 'boolean',
'published_at' => 'datetime',
'metadata' => 'array',
];
/**
* The model's default values for attributes.
*/
protected $attributes = [
'views' => 0,
'is_published' => false,
];
}
class User extends Model
{
// ✅ RECOMMENDED: Explicitly allow mass assignment
protected $fillable = [
'name', 'email', 'password', 'role'
];
// ❌ NOT RECOMMENDED: Allow all except specified
protected $guarded = [
'id', 'is_admin'
];
// ⚠️ DANGEROUS: Allow all mass assignment
protected $guarded = [];
}
// Usage examples
// ✅ This works with fillable
User::create([
'name' => 'John',
'email' => 'john@example.com',
'password' => bcrypt('secret'),
'role' => 'user'
]);
// ❌ This would fail with MassAssignmentException
User::create([
'name' => 'John',
'email' => 'john@example.com',
'is_admin' => true // Not in fillable array
]);
// Sometimes you need to bypass mass assignment protection
$user = new User;
$user->forceFill([
'name' => 'Admin',
'email' => 'admin@example.com',
'is_admin' => true
]);
$user->save();
class Post extends Model
{
protected $casts = [
// Basic types
'is_published' => 'boolean',
'views' => 'integer',
'rating' => 'float',
'price' => 'decimal:2',
// Date types
'published_at' => 'datetime',
'created_at' => 'datetime:Y-m-d H:i:s',
'scheduled_for' => 'timestamp',
// Array and JSON types
'meta' => 'array',
'settings' => 'object',
'tags' => 'collection',
// Special types
'encrypted_data' => 'encrypted',
'encrypted_array' => 'encrypted:array',
];
}
// Usage examples
$post = Post::find(1);
// Automatic casting in action
if ($post->is_published) { // Cast to boolean
// Do something
}
$post->meta = ['key' => 'value']; // Automatically encoded to JSON
$post->save();
$decodedMeta = $post->meta; // Automatically decoded from JSON
<?php
// app/Casts/MoneyCast.php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class MoneyCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
return $value / 100; // Convert cents to dollars
}
public function set($model, $key, $value, $attributes)
{
return $value * 100; // Convert dollars to cents
}
}
// In your model
class Product extends Model
{
protected $casts = [
'price' => MoneyCast::class,
];
}
// Usage
$product = new Product;
$product->price = 19.99; // Stored as 1999 in database
echo $product->price; // Outputs 19.99
class User extends Model
{
// Accessor for existing column
public function getFirstNameAttribute($value)
{
return ucfirst($value);
}
// Accessor for non-existing column (virtual)
public function getFullNameAttribute()
{
return "{$this->first_name} {$this->last_name}";
}
// Accessor with conditional logic
public function getProfileImageUrlAttribute()
{
if ($this->profile_image) {
return asset("storage/profiles/{$this->profile_image}");
}
return asset("images/default-avatar.png");
}
// Date accessor
public function getCreatedAtAttribute($value)
{
return Carbon::parse($value)->format('M j, Y');
}
}
// Usage
$user = User::find(1);
echo $user->first_name; // Automatically formatted
echo $user->full_name; // Virtual attribute
echo $user->profile_image_url; // Dynamic URL
class User extends Model
{
// Mutator for name
public function setNameAttribute($value)
{
$this->attributes['name'] = trim($value);
}
// Mutator for email (always lowercase)
public function setEmailAttribute($value)
{
$this->attributes['email'] = strtolower($value);
}
// Mutator for password (auto-hash)
public function setPasswordAttribute($value)
{
$this->attributes['password'] = bcrypt($value);
}
// Mutator with multiple operations
public function setSlugAttribute($value)
{
$this->attributes['slug'] = Str::slug($value);
$this->attributes['slug_hash'] = md5(Str::slug($value));
}
// JSON mutator
public function setSettingsAttribute($value)
{
$this->attributes['settings'] = json_encode($value);
}
}
// Usage
$user = new User;
$user->name = ' John Doe '; // Becomes 'John Doe'
$user->email = 'John@Example.COM'; // Becomes 'john@example.com'
$user->password = 'secret'; // Automatically hashed
$user->slug = 'My Great Post'; // Becomes 'my-great-post'
$user->save();
class Post extends Model
{
// Basic scope
public function scopePublished($query)
{
return $query->where('is_published', true);
}
// Scope with parameters
public function scopeCategory($query, $categoryId)
{
return $query->where('category_id', $categoryId);
}
// Scope with multiple parameters
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('created_at', [$startDate, $endDate]);
}
// Scope with relationships
public function scopeWithUser($query)
{
return $query->with('user');
}
// Dynamic scope
public function scopeStatus($query, $status)
{
return $query->where('status', $status);
}
// Complex scope with multiple conditions
public function scopePopular($query, $minViews = 1000, $minLikes = 100)
{
return $query->where('views', '>=', $minViews)
->where('likes_count', '>=', $minLikes)
->orderBy('views', 'desc');
}
}
// Usage examples
$publishedPosts = Post::published()->get();
$techPosts = Post::category(1)->published()->get();
$popularPosts = Post::popular(5000, 200)->get();
$recentPosts = Post::betweenDates('2024-01-01', '2024-01-31')->get();
<?php
// app/Scopes/ActiveScope.php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$builder->where('is_active', true);
}
}
// Applying global scope in model
class Post extends Model
{
protected static function booted()
{
static::addGlobalScope(new ActiveScope);
// Or use anonymous function
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('is_active', true);
});
}
}
// Remove global scope for specific query
$allPosts = Post::withoutGlobalScope(ActiveScope::class)->get();
$allPosts = Post::withoutGlobalScopes()->get(); // Remove all
class User extends Model
{
public function profile()
{
return $this->hasOne(Profile::class);
// return $this->hasOne(Profile::class, 'foreign_key', 'local_key');
}
}
class Profile extends Model
{
public function user()
{
return $this->belongsTo(User::class);
// return $this->belongsTo(User::class, 'foreign_key', 'owner_key');
}
}
// Usage
$user = User::find(1);
$profile = $user->profile;
$profileUser = $profile->user;
class User extends Model
{
public function posts()
{
return $this->hasMany(Post::class);
}
public function publishedPosts()
{
return $this->hasMany(Post::class)->where('is_published', true);
}
}
class Post extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
public function author()
{
return $this->belongsTo(User::class, 'author_id');
}
}
// Usage examples
$user = User::find(1);
// Get all user's posts
$posts = $user->posts;
// Get only published posts
$publishedPosts = $user->publishedPosts;
// Create post for user
$user->posts()->create([
'title' => 'New Post',
'content' => 'Post content...'
]);
// Count relationships
$postCount = $user->posts()->count();
// Eager loading
$usersWithPosts = User::with('posts')->get();
class Post extends Model
{
public function tags()
{
return $this->belongsToMany(Tag::class);
// return $this->belongsToMany(Tag::class, 'post_tag', 'post_id', 'tag_id');
}
public function tagsWithPivot()
{
return $this->belongsToMany(Tag::class)
->withPivot('created_at', 'added_by')
->withTimestamps();
}
}
class Tag extends Model
{
public function posts()
{
return $this->belongsToMany(Post::class);
}
}
// Usage examples
$post = Post::find(1);
// Attach tags
$post->tags()->attach(1);
$post->tags()->attach([1, 2, 3]);
// Detach tags
$post->tags()->detach(2);
$post->tags()->detach([1, 3]);
// Sync tags (only these tags will remain attached)
$post->tags()->sync([1, 2, 4]);
// Sync with pivot data
$post->tags()->sync([
1 => ['added_by' => auth()->id()],
2 => ['added_by' => auth()->id()],
]);
// Toggle tags
$post->tags()->toggle([1, 2, 3]);
// Access pivot data
foreach ($post->tags as $tag) {
echo $tag->pivot->created_at;
}
// Has Many Through
class Country extends Model
{
public function posts()
{
return $this->hasManyThrough(Post::class, User::class);
}
}
// Polymorphic Relationships
class Post extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
}
class Video extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}
class Comment extends Model
{
public function commentable()
{
return $this->morphTo();
}
}
// Usage
$post = Post::find(1);
$comments = $post->comments;
$video = Video::find(1);
$comments = $video->comments;
class Post extends Model
{
protected static function booted()
{
// Creating - before create
static::creating(function ($post) {
$post->slug = Str::slug($post->title);
$post->created_by = auth()->id();
});
// Created - after create
static::created(function ($post) {
// Send notifications, update cache, etc.
Cache::forget('recent_posts');
});
// Updating - before update
static::updating(function ($post) {
$post->updated_by = auth()->id();
$post->slug = Str::slug($post->title);
});
// Saving - before both create and update
static::saving(function ($post) {
$post->excerpt = Str::limit(strip_tags($post->content), 150);
});
// Saved - after both create and update
static::saved(function ($post) {
// Update search index, etc.
});
// Deleting - before delete
static::deleting(function ($post) {
// Delete related records
$post->comments()->delete();
});
}
}
php artisan make:observer PostObserver --model=Post
<?php
// app/Observers/PostObserver.php
namespace App\Observers;
use App\Models\Post;
class PostObserver
{
public function creating(Post $post)
{
$post->slug = Str::slug($post->title);
$post->created_by = auth()->id();
}
public function created(Post $post)
{
// Send notification to followers
Notification::send($post->user->followers, new NewPostNotification($post));
// Update cache
Cache::forget('recent_posts');
}
public function updating(Post $post)
{
$post->slug = Str::slug($post->title);
$post->updated_by = auth()->id();
}
public function saving(Post $post)
{
$post->excerpt = Str::limit(strip_tags($post->content), 150);
}
public function deleting(Post $post)
{
$post->comments()->delete();
$post->tags()->detach();
}
}
// Register observer in AppServiceProvider
public function boot()
{
Post::observe(PostObserver::class);
}
class Post extends Model
{
use SoftDeletes;
protected $dates = ['deleted_at'];
// Custom soft delete column
const DELETED_AT = 'deleted_date';
}
// Usage
$post = Post::find(1);
$post->delete(); // Sets deleted_at timestamp
// Include soft deleted records
$posts = Post::withTrashed()->get();
// Only soft deleted records
$deletedPosts = Post::onlyTrashed()->get();
// Restore soft deleted record
$post->restore();
// Force delete (permanently remove)
$post->forceDelete();
// Get users who have posts
$usersWithPosts = User::has('posts')->get();
// Get users with more than 5 posts
$activeUsers = User::has('posts', '>=', 5)->get();
// Get users with published posts
$usersWithPublishedPosts = User::whereHas('posts', function ($query) {
$query->where('is_published', true);
})->get();
// Get users without posts
$usersWithoutPosts = User::doesntHave('posts')->get();
// Count related models
$users = User::withCount('posts')->get();
foreach ($users as $user) {
echo $user->posts_count;
}
// Multiple counts with conditions
$users = User::withCount([
'posts',
'posts as published_posts_count' => function ($query) {
$query->where('is_published', true);
}
])->get();
class User extends Model
{
// Append accessors to JSON
protected $appends = ['full_name', 'profile_url'];
// Hide attributes from JSON
protected $hidden = ['password', 'remember_token'];
// Or use visible to specify only allowed attributes
protected $visible = ['id', 'name', 'email'];
public function toArray()
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'profile' => $this->profile,
'created_at' => $this->created_at->toISOString(),
];
}
}
// Usage
$user = User::find(1);
return response()->json($user);
// Serialize with relationships
$user->load('posts', 'profile');
return $user->toJson();
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Carbon\Carbon;
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title', 'slug', 'content', 'excerpt', 'featured_image',
'is_published', 'published_at', 'user_id', 'category_id',
'views', 'meta_title', 'meta_description'
];
protected $casts = [
'is_published' => 'boolean',
'published_at' => 'datetime',
'views' => 'integer',
'metadata' => 'array',
];
protected $attributes = [
'views' => 0,
'is_published' => false,
];
protected $appends = ['reading_time', 'published_ago'];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// Scopes
public function scopePublished($query)
{
return $query->where('is_published', true)
->where('published_at', '<=', now());
}
public function scopeDraft($query)
{
return $query->where('is_published', false)
->orWhereNull('published_at');
}
public function scopePopular($query, $minViews = 1000)
{
return $query->where('views', '>=', $minViews);
}
public function scopeRecent($query, $days = 7)
{
return $query->where('created_at', '>=', now()->subDays($days));
}
public function scopeByCategory($query, $categoryId)
{
return $query->where('category_id', $categoryId);
}
public function scopeSearch($query, $searchTerm)
{
return $query->where(function ($q) use ($searchTerm) {
$q->where('title', 'like', "%{$searchTerm}%")
->orWhere('content', 'like', "%{$searchTerm}%");
});
}
// Accessors
public function getReadingTimeAttribute(): int
{
$wordCount = str_word_count(strip_tags($this->content));
return max(1, ceil($wordCount / 200)); // 200 words per minute
}
public function getPublishedAgoAttribute(): string
{
if (!$this->published_at) {
return 'Not published';
}
return $this->published_at->diffForHumans();
}
public function getExcerptAttribute($value): string
{
return $value ?: Str::limit(strip_tags($this->content), 150);
}
public function getFeaturedImageUrlAttribute(): string
{
if ($this->featured_image) {
return asset("storage/posts/{$this->featured_image}");
}
return asset('images/default-post.jpg');
}
// Mutators
public function setTitleAttribute($value): void
{
$this->attributes['title'] = $value;
$this->attributes['slug'] = Str::slug($value);
}
public function setPublishedAtAttribute($value): void
{
$this->attributes['published_at'] = $value;
$this->attributes['is_published'] = !is_null($value);
}
// Business Logic Methods
public function publish(): bool
{
return $this->update([
'is_published' => true,
'published_at' => $this->published_at ?: now(),
]);
}
public function unpublish(): bool
{
return $this->update([
'is_published' => false,
'published_at' => null,
]);
}
public function incrementViews(): void
{
$this->increment('views');
}
public function isOwnedBy(User $user): bool
{
return $this->user_id === $user->id;
}
public function addTag($tagId): void
{
$this->tags()->syncWithoutDetaching([$tagId]);
}
// Model Events
protected static function booted(): void
{
static::creating(function ($post) {
if (empty($post->user_id) && auth()->check()) {
$post->user_id = auth()->id();
}
});
static::saving(function ($post) {
if (empty($post->excerpt)) {
$post->excerpt = Str::limit(strip_tags($post->content), 150);
}
// Generate meta fields if empty
if (empty($post->meta_title)) {
$post->meta_title = $post->title;
}
if (empty($post->meta_description)) {
$post->meta_description = $post->excerpt;
}
});
static::deleting(function ($post) {
if (!$post->isForceDeleting()) {
$post->comments()->delete();
}
});
}
}
// Usage Examples
$post = Post::create([
'title' => 'My New Blog Post',
'content' => 'This is the content of my blog post...',
'category_id' => 1,
]);
$post->publish();
$post->addTag(1);
$post->incrementViews();
// Complex queries
$popularTechPosts = Post::published()
->byCategory(1) // Technology category
->popular(5000)
->with(['user', 'category', 'tags'])
->orderBy('views', 'desc')
->take(10)
->get();
fillable specifies which attributes can be mass assigned, while guarded specifies which attributes cannot be mass assigned. It's recommended to use fillable for explicit control.
Use accessors to format attribute values when retrieving from the database, and mutators to format attribute values before saving to the database.
Query scopes are reusable query constraints that can be chained onto Eloquent queries. They help keep code DRY and make complex queries more readable.
Model events are defined directly in the model class and are good for simple logic. Observers are separate classes that handle more complex event logic and keep the model clean.
Attribute casting automatically converts attributes to common data types (boolean, integer, array, etc.) when accessing them, eliminating the need for manual type conversion.
Use the SoftDeletes trait and deleted_at column. Soft deleted records aren't removed but are excluded from normal queries. Use withTrashed(), onlyTrashed(), and restore() to work with them.
You've now mastered Laravel Eloquent Models and can efficiently interact with your database using elegant, object-oriented syntax! In our next post, we'll explore Laravel Tinker: The Powerful Tool for Testing Your Laravel Code Quickly to learn how to test and experiment with your models and application logic.