پرش به مطلب اصلی

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:

  1. Orchestrator Services: Coordinate multiple operations and services
  2. Action-Based Services: Handle specific business operations
Architecture Decision

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

Lean Entity Principle

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;
}
}
Anti-Pattern

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

Defensive Programming

Always use $fillable instead of $guarded for mass assignment protection.

Why fillable is preferred:

  1. Defensive Programming: Explicitly whitelist allowed attributes
  2. Robustness: Prevents accidental mass assignment vulnerabilities
  3. Clarity: Makes it clear which fields can be mass-assigned
  4. 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
}
Security Consideration

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');
}
}
Relationship Best Practices
  • 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}",
);
}
}
When to Use Accessors/Mutators

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',
];
}
Common Cast Types
  • boolean: Converts to boolean
  • integer: Converts to integer
  • float, double: Converts to float
  • decimal:<precision>: Converts to decimal with precision
  • string: Converts to string
  • array: JSON to array
  • json: JSON to object
  • collection: JSON to Collection
  • date, datetime: Converts to Carbon instance
  • timestamp: Converts to UNIX timestamp
  • encrypted: 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 database
  • creating: Before a new model is saved for the first time
  • created: After a new model is saved for the first time
  • updating: Before an existing model is updated
  • updated: After an existing model is updated
  • saving: Before a model is created or updated
  • saved: After a model is created or updated
  • deleting: Before a model is deleted
  • deleted: After a model is deleted
  • trashed: After a model is soft deleted
  • forceDeleting: Before a model is force deleted
  • forceDeleted: After a model is force deleted
  • restoring: Before a soft deleted model is restored
  • restored: After a soft deleted model is restored
  • replicating: 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;
}
When to Use Model Contracts

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

ComponentBelongs in EntityDelegate 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

  1. Keep entities lean: Focus on data representation and database interactions
  2. Use $fillable over $guarded: Explicit whitelisting for security
  3. Leverage Laravel features: Relationships, accessors, mutators, casts, events
  4. Implement contracts: Use interfaces for model-specific behaviors
  5. Delegate complexity: Move business logic to services, queries to repositories

Decision Tree: Where Should This Code Go?

Is it a relationship definition?
├─ YesEntity
└─ NoIs it a simple attribute transformation?
├─ YesAccessor/Mutator in Entity
└─ NoIs it a database-level event?
├─ Yes (simple)Model event in Entity
├─ Yes (complex)Event listener outside Entity
└─ NoIs it a simple check on model attributes?
├─ YesModel contract in Entity
└─ NoService (Orchestrator or Action-Based)