Entities (Models)
Entities are Eloquent models that represent database tables and handle data persistence. This guide outlines the architectural principles, conventions, and best practices for working with entities in the Planet project.
Architectural Context
Service Layer Architecture
The Planet project follows a unified service architecture where services are not separated from actions. Instead, we have a general service concept with two subtypes:
- Orchestrator Services: Coordinate multiple operations and services
- Action-Based Services: Handle specific business operations
Unlike some architectures that separate Actions from Services, we maintain them as a unified concept. This design decision eliminates unnecessary decision-making overhead for developers about whether to create an Action or a Service, unless there are clear and explicit criteria for separation.
Responsibility Delegation
Entities should delegate complex operations to:
- Repositories: For complex queries and data retrieval patterns
- Services (both Orchestrator and Action-Based): For business logic and operations
Core Principles
1. Keep Entities Lean
Entities should remain lean and focused solely on data representation and database-level interactions.
Entities should only contain:
- Property definitions (
$fillable,$casts,$hidden, etc.) - Relationships (via Eloquent relationship methods)
- Accessors and Mutators (attribute transformation)
- Database-level events (model lifecycle hooks)
- Model-level contracts (interfaces specific to the model)
Avoid adding complex business logic, data manipulation, or query operations directly in entities.
Example: Lean Entity
<?php
namespace App\Entities;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
protected $fillable = [
'title',
'content',
'user_id',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
// Relationship
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Simple accessor
public function getIsPublishedAttribute(): bool
{
return $this->published_at !== null;
}
}
Don't add complex business logic or queries directly in entities:
// ❌ Avoid this
class Post extends Model
{
public function publishWithNotifications()
{
$this->published_at = now();
$this->save();
// Complex business logic doesn't belong here
$this->user->notify(new PostPublished($this));
Cache::forget('posts');
// ...
}
}
Use services instead (Orchestrator or Action-Based):
// ✅ Better approach - Action-Based Service
class PostPublishService
{
public function __construct(
private NotificationService $notificationService,
private CacheService $cacheService
) {}
public function publish(Post $post): void
{
$post->published_at = now();
$post->save();
$this->notificationService->notifyPostPublished($post);
$this->cacheService->invalidatePostsCache();
}
}
2. Use fillable Over guarded
Always use $fillable instead of $guarded for mass assignment protection.
Why fillable is preferred:
- Defensive Programming: Explicitly whitelist allowed attributes
- Robustness: Prevents accidental mass assignment vulnerabilities
- Clarity: Makes it clear which fields can be mass-assigned
- Maintainability: Easier to review and audit security
Comparison
// ❌ Avoid using guarded
class User extends Model
{
protected $guarded = ['id', 'is_admin'];
// Problem: All other fields are fillable by default
// New fields added to the table are automatically fillable
}
// ✅ Use fillable instead
class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
];
// Benefit: Only explicitly listed fields can be mass-assigned
// New fields require explicit addition to the list
}
Using $guarded can lead to security vulnerabilities when new columns are added to the database table. With $fillable, you must explicitly allow new fields, reducing the risk of unintended mass assignment.
Best Practice Example
<?php
namespace App\Entities;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'name',
'description',
'price',
'stock',
'category_id',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'price' => 'decimal:2',
'stock' => 'integer',
];
}
What Belongs in Entities
1. Relationships
Eloquent relationships define how models relate to each other. These are fundamental to the model and should always be defined within the entity.
<?php
namespace App\Entities;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Post extends Model
{
// One-to-Many (Inverse)
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
// One-to-Many
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// Many-to-Many
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)
->withTimestamps()
->withPivot('order');
}
}
- Always use type hints for return types
- Use descriptive method names that reflect the relationship
- Document complex relationships with PHPDoc comments
2. Accessors and Mutators
Accessors and mutators allow you to transform attribute values when retrieving or setting them. These are attribute-level transformations and belong in the model.
Modern Approach (Laravel 9+)
<?php
namespace App\Entities;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Get the user's full name.
*/
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
}
Use accessors and mutators for:
- Simple data transformations
- Formatting output (dates, currency, etc.)
- Computed attributes based on other model attributes
- Normalizing input data (lowercase emails, trim whitespace, etc.)
Don't use them for:
- Complex business logic
- Database queries
- External API calls
- Operations that depend on multiple models
3. Attribute Casting
Casts automatically convert attributes to common data types. This is a core Laravel feature that belongs in the model.
<?php
namespace App\Entities;
use App\Enums\PostStatus;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'published_at' => 'datetime',
'is_featured' => 'boolean',
'view_count' => 'integer',
'rating' => 'decimal:2',
'metadata' => 'array',
'settings' => 'json',
'status' => PostStatus::class, // Enum casting
'tags' => 'collection',
'encrypted_data' => 'encrypted',
];
}
boolean: Converts to booleaninteger: Converts to integerfloat,double: Converts to floatdecimal:<precision>: Converts to decimal with precisionstring: Converts to stringarray: JSON to arrayjson: JSON to objectcollection: JSON to Collectiondate,datetime: Converts to Carbon instancetimestamp: Converts to UNIX timestampencrypted: Encrypts/decrypts automatically- Custom Enum classes for enum casting
4. Database-Level Events
Model events allow you to hook into specific moments in a model's lifecycle. These are database-level operations and belong in the model.
Available Events
Laravel provides the following model events:
retrieved: After a model is retrieved from the databasecreating: Before a new model is saved for the first timecreated: After a new model is saved for the first timeupdating: Before an existing model is updatedupdated: After an existing model is updatedsaving: Before a model is created or updatedsaved: After a model is created or updateddeleting: Before a model is deleteddeleted: After a model is deletedtrashed: After a model is soft deletedforceDeleting: Before a model is force deletedforceDeleted: After a model is force deletedrestoring: Before a soft deleted model is restoredrestored: After a soft deleted model is restoredreplicating: Before a model is replicated
5. Model-Level Contracts
When a method needs to be added to a model that is neither a relationship, accessor, mutator, nor an event, it should be added as a model-level contract (interface implementation).
What are Model-Level Contracts?
Model-level contracts are interfaces that define specific behaviors or capabilities that a model must implement. They provide a clear contract for what operations a model supports.
<?php
namespace App\Contracts;
interface Publishable
{
public function isPublished(): bool;
public function isDraft(): bool;
public function canBePublished(): bool;
}
<?php
namespace App\Entities;
use App\Contracts\Publishable;
use Illuminate\Database\Eloquent\Model;
class Post extends Model implements Publishable
{
/**
* Check if the post is published.
*/
public function isPublished(): bool
{
return $this->published_at !== null
&& $this->published_at->isPast();
}
/**
* Check if the post is a draft.
*/
public function isDraft(): bool
{
return $this->published_at === null;
}
/**
* Check if the post can be published.
*/
public function canBePublished(): bool
{
return !empty($this->title)
&& !empty($this->content)
&& $this->status === 'ready';
}
}
Common Model Contracts
<?php
namespace App\Contracts;
// Sortable models
interface Sortable
{
public function moveUp(): bool;
public function moveDown(): bool;
public function moveTo(int $position): bool;
}
// Sluggable models
interface Sluggable
{
public function generateSlug(): string;
public function getSlugSource(): string;
}
// Searchable models
interface Searchable
{
public function toSearchableArray(): array;
public function getSearchableFields(): array;
}
// Activatable models
interface Activatable
{
public function activate(): bool;
public function deactivate(): bool;
public function isActive(): bool;
}
Use model-level contracts when:
- The method operates solely on the model's own attributes
- The method returns a simple value or boolean
- The method doesn't involve complex business logic
- The method doesn't query other models or external services
- The behavior is intrinsic to the model's nature
Examples of good contract methods:
isPublished(),isDraft(),isActive()canBeDeleted(),canBeEdited()getDisplayName(),getStatusLabel()hasExpired(),isUpcoming()
What Doesn't Belong in Entities
❌ Complex Business Logic
// ❌ Don't do this
class Order extends Model
{
public function processPayment(array $paymentData): bool
{
// Complex payment processing logic
$gateway = PaymentGateway::create($paymentData['method']);
$result = $gateway->charge($this->total);
if ($result->success) {
$this->status = 'paid';
$this->save();
// Send notifications
$this->customer->notify(new OrderPaid($this));
// Update inventory
foreach ($this->items as $item) {
$item->product->decrementStock($item->quantity);
}
return true;
}
return false;
}
}
// ✅ Use a service instead
class OrderPaymentService
{
public function __construct(
private PaymentGateway $gateway,
private NotificationService $notifications,
private InventoryService $inventory
) {}
public function processPayment(Order $order, array $paymentData): bool
{
$result = $this->gateway->charge(
$order->total,
$paymentData['method']
);
if ($result->success) {
$order->markAsPaid();
$this->notifications->notifyOrderPaid($order);
$this->inventory->decrementForOrder($order);
return true;
}
return false;
}
}
❌ Complex Queries
// ❌ Don't do this
class User extends Model
{
public function getActiveSubscribersWithPendingOrders()
{
return self::whereHas('subscription', function ($query) {
$query->where('status', 'active')
->where('expires_at', '>', now());
})
->whereHas('orders', function ($query) {
$query->where('status', 'pending')
->where('created_at', '>', now()->subDays(7));
})
->with(['subscription', 'orders.items'])
->get();
}
}
// ✅ Use a repository instead
class UserRepository
{
public function getActiveSubscribersWithPendingOrders(): Collection
{
return User::query()
->whereHas('subscription', function ($query) {
$query->where('status', 'active')
->where('expires_at', '>', now());
})
->whereHas('orders', function ($query) {
$query->where('status', 'pending')
->where('created_at', '>', now()->subDays(7));
})
->with(['subscription', 'orders.items'])
->get();
}
}
❌ External Service Calls
// ❌ Don't do this
class Product extends Model
{
public function syncWithExternalInventory(): bool
{
$client = new InventoryApiClient();
$response = $client->getStock($this->sku);
$this->stock = $response['quantity'];
$this->save();
return true;
}
}
// ✅ Use a service instead
class ProductSyncService
{
public function __construct(
private InventoryApiClient $client
) {}
public function syncStock(Product $product): bool
{
$response = $this->client->getStock($product->sku);
$product->stock = $response['quantity'];
$product->save();
return true;
}
}
Quick Reference
| Component | Belongs in Entity | Delegate to Service/Repository |
|---|---|---|
| Relationships | ✅ Yes | ❌ No |
| Accessors/Mutators | ✅ Yes (simple transformations) | ❌ No |
| Attribute Casts | ✅ Yes | ❌ No |
| Database Events | ✅ Yes (simple operations) | ⚠️ Complex logic to services |
| Model Contracts | ✅ Yes (simple checks) | ⚠️ Complex logic to services |
| Complex Queries | ❌ No | ✅ Repository |
| Business Logic | ❌ No | ✅ Service (Orchestrator/Action-Based) |
| External APIs | ❌ No | ✅ Service |
| Multi-Model Operations | ❌ No | ✅ Service (Orchestrator) |
Summary
Key Principles
- Keep entities lean: Focus on data representation and database interactions
- Use
$fillableover$guarded: Explicit whitelisting for security - Leverage Laravel features: Relationships, accessors, mutators, casts, events
- Implement contracts: Use interfaces for model-specific behaviors
- Delegate complexity: Move business logic to services, queries to repositories
Decision Tree: Where Should This Code Go?
Is it a relationship definition?
├─ Yes → Entity
└─ No → Is it a simple attribute transformation?
├─ Yes → Accessor/Mutator in Entity
└─ No → Is it a database-level event?
├─ Yes (simple) → Model event in Entity
├─ Yes (complex) → Event listener outside Entity
└─ No → Is it a simple check on model attributes?
├─ Yes → Model contract in Entity
└─ No → Service (Orchestrator or Action-Based)