Laravel Model State Graph provides a powerful, flexible way to enforce complex business rules and state transitions in your Eloquent models. Instead of scattering validation logic across controllers, form requests, and model observers, this package lets you define field-level business rules that are conditionally applied based on your model's current state.
Think of it as a state machine for individual model fields, where you can control what changes are allowed, when they're allowed, and under what conditions.
This package is ideal for scenarios where you need to:
- Enforce state transitions: Control valid status workflows (e.g., draft → pending → approved → shipped)
- Manage inventory constraints: Ensure quantity changes respect stock levels, daily limits, and contractual obligations
- Implement approval workflows: Require different validation rules based on user roles or approval states
- Guard critical fields: Prevent invalid updates to prices, quantities, or statuses that could break business logic
- Maintain data integrity: Ensure models maintain valid states throughout their lifecycle
- E-commerce order management with complex status transitions
- Inventory systems with minimum/maximum stock rules
- Document approval workflows with role-based validations
- Pricing systems requiring approval for discounts above certain thresholds
- Booking systems where cancellations have state-dependent rules
- PHP 8.2 or higher
- Laravel 10.0 or higher
Install the package via composer:
composer require androlax2/laravel-model-state-graph
The package is built around three main interfaces:
Individual validation rules that can be conditionally applied:
interface BusinessRule
{
/**
* Determine if this rule applies to the current model state
*/
public function supports(Model $model): bool;
/**
* Validate the model against this rule
* @throws BusinessRuleViolationException
*/
public function validate(Model $model): void;
}
Groups related business rules for a specific model field:
interface FieldRuleSet
{
/**
* The field name this rule set validates
*/
public function getField(): string;
/**
* All business rules for this field
* @return BusinessRule[]
*/
public function getRules(): array;
/**
* Determine if this rule set should run for the current model state
*/
public function supports(Model $model): bool;
}
Coordinates rule execution:
$graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(new QuantityRuleSet())
->addFieldRuleSet(new PriceRuleSet());
if ($graph->isValid($model)) {
// All rules passed
} else {
$violations = $graph->getViolations($model);
// Handle violations
}
Let's start with the simplest possible example - validating a single field:
<?php
namespace App\Rules;
use Androlax\LaravelModelStateGraph\Contracts\BusinessRule;
use Androlax\LaravelModelStateGraph\Exceptions\BusinessRuleViolationException;
use App\Models\Product;
class QuantityMustBePositiveRule implements BusinessRule
{
public function supports(Product $model): bool
{
// Only apply when quantity changes
return $model->isDirty('quantity');
}
public function validate(Product $model): void
{
if ($model->quantity < 0) {
throw new BusinessRuleViolationException('Quantity must be positive');
}
}
}
<?php
namespace App\RuleSets;
use Androlax\LaravelModelStateGraph\Contracts\FieldRuleSet;
use App\Models\Product;
use App\Rules\QuantityMustBePositiveRule;
class QuantityRuleSet implements FieldRuleSet
{
public function getField(): string
{
return 'quantity';
}
public function getRules(): array
{
return [
new QuantityMustBePositiveRule(),
];
}
public function supports(Product $model): bool
{
return $model->isDirty('quantity');
}
}
<?php
use Androlax\LaravelModelStateGraph\ModelStateGraph;
use App\RuleSets\QuantityRuleSet;
$product = Product::first();
$product->quantity = -5;
$graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(new QuantityRuleSet());
if ($graph->isValid($product)) {
$product->save();
} else {
$violations = $graph->getViolations($product);
// Output: ["Quantity must be positive"]
}
<?php
class QuantityRuleSet implements FieldRuleSet
{
public function getField(): string
{
return 'quantity';
}
public function getRules(): array
{
return [
new QuantityMustBePositiveRule(),
new QuantityIncreaseRule(),
new QuantityDecreaseRule(),
new QuantityRangeRule(),
];
}
public function supports(Product $model): bool
{
return $model->isDirty('quantity');
}
}
Rules that only apply in specific scenarios:
<?php
class QuantityIncreaseRule implements BusinessRule
{
public function supports(Product $model): bool
{
// Only when increasing quantity
return $model->isDirty('quantity') &&
$model->quantity > $model->getOriginal('quantity');
}
public function validate(Product $model): void
{
$increase = $model->quantity - $model->getOriginal('quantity');
if ($increase > $model->max_daily_increase) {
throw new BusinessRuleViolationException(
"Quantity increase of {$increase} exceeds daily limit of {$model->max_daily_increase}"
);
}
if (!$this->hasSufficientInventory($increase)) {
throw new BusinessRuleViolationException('Insufficient inventory for quantity increase');
}
}
private function hasSufficientInventory(int $increase): bool
{
// Your inventory check logic
return true;
}
}
class QuantityDecreaseRule implements BusinessRule
{
public function supports(Product $model): bool
{
// Only when decreasing quantity
return $model->isDirty('quantity') &&
$model->quantity < $model->getOriginal('quantity');
}
public function validate(Product $model): void
{
if ($model->quantity < $model->minimum_stock) {
throw new BusinessRuleViolationException(
"Quantity cannot decrease below minimum stock level of {$model->minimum_stock}"
);
}
}
}
<?php
$graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(new QuantityRuleSet())
->addFieldRuleSet(new PriceRuleSet())
->addFieldRuleSet(new StatusRuleSet());
$product->fill([
'quantity' => 25,
'price' => 99.99,
'status' => 'active'
]);
if ($graph->isValid($product)) {
$product->save();
} else {
$violations = $graph->getViolations($product);
foreach ($violations as $violation) {
echo $violation . "\n";
}
}
Control complex state machine workflows:
<?php
class StatusTransitionRule implements BusinessRule
{
private array $allowedTransitions = [
'draft' => ['pending', 'cancelled'],
'pending' => ['approved', 'rejected', 'cancelled'],
'approved' => ['shipped', 'cancelled'],
'shipped' => ['delivered'],
'delivered' => [], // Terminal state
];
public function supports(Product $model): bool
{
return $model->isDirty('status');
}
public function validate(Product $model): void
{
$from = $model->getOriginal('status');
$to = $model->status;
$allowed = $this->allowedTransitions[$from] ?? [];
if (!in_array($to, $allowed)) {
throw new BusinessRuleViolationException(
"Cannot transition from '{$from}' to '{$to}'. Allowed transitions: " . implode(', ', $allowed)
);
}
}
}
Inject services and apply rules based on feature flags, user roles, or other context:
<?php
class ConditionalPriceRuleSet implements FieldRuleSet
{
public function __construct(
private FeatureFlagService $features,
private User $currentUser
) {}
public function getField(): string
{
return 'price';
}
public function getRules(): array
{
$rules = [
new PriceRangeRule(),
new PriceChangeLimitRule(),
];
// Admin users can override price limits
if ($this->currentUser->hasRole('admin')) {
$rules[] = new AdminPriceOverrideRule();
}
// Add experimental pricing rules when feature is enabled
if ($this->features->isEnabled('dynamic_pricing')) {
$rules[] = new DynamicPricingRule();
}
return $rules;
}
public function supports(Product $model): bool
{
// Skip validation for free products
return $model->isDirty('price') && $model->category !== 'free';
}
}
// Usage with dependency injection
$graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(
new ConditionalPriceRuleSet(
app(FeatureFlagService::class),
auth()->user()
)
);
<?php
class PriceRequiresApprovalRule implements BusinessRule
{
private const APPROVAL_THRESHOLD_PERCENT = 20;
public function __construct(
private ApprovalService $approvalService
) {}
public function supports(Product $model): bool
{
if (!$model->isDirty('price')) {
return false;
}
$originalPrice = $model->getOriginal('price');
$newPrice = $model->price;
$percentChange = abs(($newPrice - $originalPrice) / $originalPrice * 100);
return $percentChange >= self::APPROVAL_THRESHOLD_PERCENT;
}
public function validate(Product $model): void
{
$approval = $this->approvalService->findPendingApproval($model, 'price_change');
if (!$approval || !$approval->isApproved()) {
throw new BusinessRuleViolationException(
'Price changes over 20% require manager approval'
);
}
}
}
You can integrate the graph into your model lifecycle using Laravel events:
<?php
namespace App\Observers;
use Androlax\LaravelModelStateGraph\ModelStateGraph;
use App\Models\Product;
use App\RuleSets\QuantityRuleSet;
use App\RuleSets\PriceRuleSet;
use App\RuleSets\StatusRuleSet;
class ProductObserver
{
private ModelStateGraph $graph;
public function __construct()
{
$this->graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(new QuantityRuleSet())
->addFieldRuleSet(new PriceRuleSet())
->addFieldRuleSet(new StatusRuleSet());
}
public function saving(Product $product): bool
{
if (!$this->graph->isValid($product)) {
$violations = $this->graph->getViolations($product);
// Log violations
logger()->warning('Product validation failed', [
'product_id' => $product->id,
'violations' => $violations,
]);
// Prevent save
return false;
}
return true;
}
}
The package provides specific exceptions for different error scenarios:
<?php
use Androlax\LaravelModelStateGraph\ModelStateGraph;
use Androlax\LaravelModelStateGraph\Exceptions\InvalidFieldException;
use Androlax\LaravelModelStateGraph\Exceptions\DuplicateFieldException;
use Androlax\LaravelModelStateGraph\Exceptions\BusinessRuleViolationException;
try {
$graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(new QuantityRuleSet())
->addFieldRuleSet(new PriceRuleSet());
if (!$graph->isValid($product)) {
$violations = $graph->getViolations($product);
// Log each violation
foreach ($violations as $violation) {
logger()->warning('Business rule violation', [
'model' => get_class($product),
'model_id' => $product->id,
'message' => $violation,
]);
}
// Return to user with errors
return back()->withErrors([
'validation' => 'The product state is invalid: ' . implode(', ', $violations)
]);
}
$product->save();
} catch (InvalidFieldException $e) {
// Field doesn't exist on the model
report($e);
return back()->withErrors([
'field' => 'Invalid field configuration: ' . $e->getMessage()
]);
} catch (DuplicateFieldException $e) {
// Multiple rule sets defined for the same field
report($e);
return back()->withErrors([
'configuration' => 'Duplicate field rule sets: ' . $e->getMessage()
]);
}
<?php
use Tests\Fixtures\Product;
use App\Rules\QuantityIncreaseRule;
it('allows quantity increases within limits', function () {
$product = Product::create([
'quantity' => 10,
'max_daily_increase' => 50,
]);
$product->quantity = 25; // Increase of 15
$rule = new QuantityIncreaseRule();
expect($rule->supports($product))->toBeTrue();
// Should not throw exception
$rule->validate($product);
});
it('prevents quantity increases exceeding daily limit', function () {
$product = Product::create([
'quantity' => 10,
'max_daily_increase' => 20,
]);
$product->quantity = 50; // Increase of 40, exceeds limit
$rule = new QuantityIncreaseRule();
expect(fn() => $rule->validate($product))
->toThrow(BusinessRuleViolationException::class, 'exceeds daily limit');
});
<?php
use App\RuleSets\QuantityRuleSet;
it('only supports models with dirty quantity field', function () {
$product = Product::create(['quantity' => 10]);
$ruleSet = new QuantityRuleSet();
expect($ruleSet->supports($product))->toBeFalse();
$product->quantity = 20;
expect($ruleSet->supports($product))->toBeTrue();
});
it('includes all quantity-related rules', function () {
$ruleSet = new QuantityRuleSet();
$rules = $ruleSet->getRules();
expect($rules)->toHaveCount(3);
expect($rules[0])->toBeInstanceOf(QuantityIncreaseRule::class);
});
<?php
it('validates complete product updates', function () {
$product = Product::create([
'quantity' => 10,
'price' => 50.00,
'status' => 'draft',
]);
$product->fill([
'quantity' => 25,
'price' => 45.00,
'status' => 'pending',
]);
$graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(new QuantityRuleSet())
->addFieldRuleSet(new PriceRuleSet())
->addFieldRuleSet(new StatusRuleSet());
expect($graph->isValid($product))->toBeTrue();
});
it('catches invalid status transitions', function () {
$product = Product::create(['status' => 'draft']);
$product->status = 'shipped'; // Invalid: draft can't go directly to shipped
$graph = ModelStateGraph::for(Product::class)
->addFieldRuleSet(new StatusRuleSet());
expect($graph->isValid($product))->toBeFalse();
expect($graph->getViolations($product))
->toContain('Cannot transition from \'draft\' to \'shipped\'');
});
Each business rule should validate one specific concern:
// Good: Focused rule
class QuantityMustBePositiveRule implements BusinessRule { ... }
class QuantityWithinRangeRule implements BusinessRule { ... }
// Bad: Rule doing too much
class QuantityValidationRule implements BusinessRule { ... } // validates everything
Skip expensive validation when rules don't apply:
public function supports(Product $model): bool
{
// Quick check: only run when relevant
if (!$model->isDirty('price')) {
return false;
}
// More expensive checks only if needed
return $model->category === 'premium';
}
Help users understand what went wrong and how to fix it:
// Good: Clear and actionable
throw new BusinessRuleViolationException(
"Quantity increase of {$increase} exceeds daily limit of {$limit}. Try again tomorrow or request approval."
);
// Bad: Vague
throw new BusinessRuleViolationException("Invalid quantity");
Since RuleSets and Rules are tied to specific models, organize them by model for better clarity and maintainability:
app/
├── Models/
│ ├── Product.php
│ └── Order.php
└── BusinessRules/
├── Product/
│ ├── Quantity/
│ │ ├── QuantityRuleSet.php
│ │ ├── QuantityIncreaseRule.php
│ │ ├── QuantityDecreaseRule.php
│ │ └── QuantityRangeRule.php
│ ├── Price/
│ │ ├── PriceRuleSet.php
│ │ ├── PriceRangeRule.php
│ │ └── PriceApprovalRule.php
│ └── Status/
│ ├── StatusRuleSet.php
│ └── StatusTransitionRule.php
└── Order/
├── Status/
│ ├── StatusRuleSet.php
│ └── OrderStatusTransitionRule.php
└── Payment/
├── PaymentRuleSet.php
└── PaymentValidationRule.php
This structure groups related rules by their field/concern, making it easy to find and maintain all rules for a specific field.
Use Laravel's container for flexibility and testability:
class PriceRuleSet implements FieldRuleSet
{
public function __construct(
private PricingService $pricing,
private ?User $user = null
) {
$this->user ??= auth()->user();
}
public function getRules(): array
{
return [
new PriceRangeRule($this->pricing),
new PriceApprovalRule($this->user),
];
}
}
Write tests for:
- Individual rule logic
- Rule support conditions
- Complete validation scenarios
- Edge cases and error conditions
When implementing complex status transitions, document them:
/**
* Order Status State Machine
*
* draft → pending → approved → shipped → delivered
* ↓ ↓ ↓
* cancelled
*
* Business Rules:
* - Orders can be cancelled at any stage before delivery
* - Shipped orders require tracking number
* - Approved orders require payment confirmation
*/
class StatusTransitionRule implements BusinessRule { ... }
- The graph only runs rules for fields that have changed (
isDirty()
) - Use the
supports()
method to skip expensive validation early - Rules are evaluated lazily - validation stops at the first violation
- Consider caching expensive lookups within rules for the same request
Laravel Model State Graph complements Laravel's built-in validation but serves a different purpose:
Feature | Laravel Validation | Model State Graph |
---|---|---|
Use Case | Request input validation | Business logic validation |
Context | HTTP layer | Model layer |
State Awareness | Limited | Full state transition support |
Conditional Logic | Basic | Complex, context-aware |
Integration | Form Requests | Model lifecycle events |
Use both together: Laravel validation for input sanitization, Model State Graph for business rule enforcement.
Please see CHANGELOG for more information on what has changed recently.
If you've found a bug regarding security please mail theo.benoit16@gmail.com instead of using the issue tracker.
The MIT License (MIT). Please see License File for more information.