androlax2 / laravel-model-state-graph
A powerful state transition system for Laravel Eloquent models that provides field-level validation and business rule enforcement.
Fund package maintenance!
Théo Benoit
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/androlax2/laravel-model-state-graph
Requires
- php: ^8.2
- illuminate/contracts: ^10.0|^11.0|^12.0
Requires (Dev)
- canvural/larastan-strict-rules: ^2.0||^3.0
- driftingly/rector-laravel: ^2.0
- larastan/larastan: ^2.0||^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^7.0|^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.7|^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-type-coverage: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: ^2.2
- symfony/var-dumper: ^7.3
This package is auto-updated.
Last update: 2025-10-11 17:58:09 UTC
README
About
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.
When to Use
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
Real-World Examples
- 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
Requirements
- PHP 8.2 or higher
- Laravel 10.0 or higher
Installation
Install the package via composer:
composer require androlax2/laravel-model-state-graph
Core Concepts
The package is built around three main interfaces:
BusinessRule Interface
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; }
FieldRuleSet Interface
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; }
ModelStateGraph
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 }
Quick Start
Let's start with the simplest possible example - validating a single field:
Step 1: Create a Business Rule
<?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'); } } }
Step 2: Create a Field Rule Set
<?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'); } }
Step 3: Validate Your Model
<?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"] }
Basic Usage
Multiple Rules for a Single Field
<?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'); } }
Conditional Business Rules
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}" ); } } }
Validating Multiple Fields
<?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"; } }
Advanced Usage
Status Transition Rules
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) ); } } }
Conditional Rule Sets with Dependencies
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() ) );
Complex Business Rules with External Dependencies
<?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' ); } } }
Integration with Laravel Events
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; } }
Error Handling
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() ]); }
Testing
Testing Business Rules
<?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'); });
Testing Field Rule Sets
<?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); });
Testing Full Validation
<?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\''); });
Best Practices
1. Keep Rules Focused and Single-Purpose
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
2. Use supports()
Efficiently
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'; }
3. Provide Clear, Actionable Violation Messages
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");
4. Organize Rules by Model
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.
5. Leverage Dependency Injection
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), ]; } }
6. Test Thoroughly
Write tests for:
- Individual rule logic
- Rule support conditions
- Complete validation scenarios
- Edge cases and error conditions
7. Document Your State Machines
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 { ... }
Performance Considerations
- 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
Comparison with Laravel Validation
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.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Security Vulnerabilities
If you've found a bug regarding security please mail theo.benoit16@gmail.com instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.