roddy / stateforge
Elegant, simple state management for Laravel applications.
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 5
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/roddy/stateforge
Requires
- php: ^8.0
- illuminate/contracts: ^9.0 || ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^9.0 || ^10.0 || ^11.0 || ^12.0
Requires (Dev)
- illuminate/console: ^9.0 || ^10.0 || ^11.0 || ^12.0
- illuminate/http: ^9.0 || ^10.0 || ^11.0 || ^12.0
README
Elegant, Isolated State Management for Laravel
StateForge is a powerful, elegant state management package for Laravel that provides isolated, persistent state stores with automatic discovery and multiple persistence options. Built with developer experience in mind, it brings the simplicity of client-side state management to your Laravel applications.
๐ Table of Contents
- Features
- Installation
- Quick Start
- Core Concepts
- Store Lifecycle
- Store Examples
- Events & Hooks
- API Reference
- Persistence Options
- Advanced Usage
- Configuration
- Maintenance
- Best Practices
- Troubleshooting
- FAQ
- Contributing
- License
โจ Features
- ๐ Auto-discovery - Stores automatically discovered and registered
- ๐ User Isolation - Each browser/user gets completely isolated stores
- ๐พ Multiple Persistence - File, cache, session, or in-memory storage
- ๐ฏ Type Safety - Use class references instead of magic strings
- ๐ Persistence Across Sessions - State survives browser restarts
- ๐งฉ Lifecycle Hooks -
onUpdate()for automatic state change handling - ๐ Custom Middlewares - Extend store behavior with middleware arrays
- ๐ญ Event System - Before/after hooks and global events
- ๐ Artisan Commands - Generate stores and manage cleanup
- ๐ฆ Zero Configuration - Works out of the box with sensible defaults
- ๐ Browser Fingerprinting - Fallback identification when cookies are cleared
- ๐ก๏ธ Data Integrity - Checksum verification for persisted data
๐ Installation
Requirements
- PHP 8.0 or higher
- Laravel 9.0 or higher
Install via Composer
composer require roddy/stateforge
Publish Configuration (Optional)
php artisan vendor:publish --provider="Roddy\\StateForge\\StateForgeServiceProvider" --tag=stateforge-config
๐ฏ Quick Start
1. Create Your First Store
php artisan make:store Counter
This creates app/Stores/CounterStore.php with the enhanced architecture:
<?php namespace App\Stores; use Roddy\StateForge\Stores\BaseStore; class CounterStore extends BaseStore { protected string $persistenceType = 'file'; protected function initializeState(): array { return [ 'count' => 0, 'created_at' => now()->toISOString(), 'increment' => function (int $by = 1) { $this->setState(fn($state) => array_merge($state, [ 'count' => $state['count'] + $by, 'updated_at' => now()->toISOString() ])); }, 'decrement' => function (int $by = 1) { $this->setState(fn($state) => array_merge($state, [ 'count' => $state['count'] - $by, 'updated_at' => now()->toISOString() ])); }, 'resetState' => function () { $this->setState(fn($state) => array_merge($state, [ 'count' => 0, 'updated_at' => now()->toISOString() ])); }, 'getInfo' => function () { return [ 'count' => $this->count, 'created_at' => $this->created_at, 'persistence' => $this->getPersistenceType() ]; } ]; } protected function middlewares(): array { return []; // Add custom middlewares here. Closure or Class } protected function onUpdate(array $previousState, array $newState): void { // Automatic logging on every state change \Log::info('Counter state updated', [ 'previous_count' => $previousState['count'] ?? 0, 'new_count' => $newState['count'] ?? 0, 'changes' => array_diff_assoc($newState, $previousState) ]); } }
2. Use in Controller
<?php namespace App\Http\Controllers; use Roddy\StateForge\Facades\StateForge; use App\Stores\CounterStore; class CounterController extends Controller { public function show() { $counter = StateForge::get(CounterStore::class); // or useStore(CounterStore::class) return response()->json([ 'count' => $counter->count, 'info' => $counter->getInfo() ]); } public function increment() { $counter = StateForge::get(CounterStore::class); // or useStore(CounterStore::class) $counter->increment(); // onUpdate() is automatically called with logging! return response()->json([ 'success' => true, 'new_count' => $counter->count ]); } }
3. Add Routes
// routes/web.php or routes/api.php Route::get('/counter', [CounterController::class, 'show']); Route::post('/counter/increment', [CounterController::class, 'increment']);
4. Test It Out!
Visit /counter in your browser and see the counter in action! Each browser will have its own isolated counter, and all state changes are automatically logged.
๐ง Core Concepts
Enhanced Store Architecture
StateForge 2.0 introduces a more structured store architecture:
class YourStore extends BaseStore { // 1. Define persistence type (file, cache, session, none) protected string $persistenceType = 'file'; // 2. Initialize your state and methods protected function initializeState(): array { /* ... */ } // 3. Add custom middlewares protected function middlewares(): array { /* ... */ } // 4. Handle state changes automatically protected function onUpdate(array $previousState, array $newState): void { /* ... */ } }
Lifecycle Methods
initializeState()- Define your state properties and methodsmiddlewares()- Return array of custom middlewaresonUpdate()- Called automatically after every state change- Automatic Persistence - Based on
$persistenceType
User Isolation
StateForge automatically isolates stores per user/browser combination:
- โ Same browser, different tabs: Same store
- โ Browser restart: Same store (persisted)
- โ Different browser: Different store
- โ Different device: Different store
Auto-discovery
Stores placed in app/Stores/ are automatically discovered and registered. No manual configuration needed!
๐ Store Lifecycle
Complete Lifecycle Flow
class UserStore extends BaseStore { protected string $persistenceType = 'file'; protected function initializeState(): array { return [ 'user' => null, 'is_logged_in' => false, // ... methods ]; } protected function middlewares(): array { return [ // Custom middleware that runs on every state change function(callable $updater, array $state) { // Called before state update Log::debug('Middleware: Before update'); $newState = $updater($state); Log::debug('Middleware: After update'); return $newState; } ]; } protected function onUpdate(array $previousState, array $newState): void { // Automatic handling after every state change if ($previousState['is_logged_in'] !== $newState['is_logged_in']) { event(new UserLoginStatusChanged( $newState['is_logged_in'], $newState['user'] )); } } }
Middleware System
Add custom behavior to your stores:
protected function middlewares(): array { return [ // Validation middleware function(callable $updater, array $state) { $newState = $updater($state); // Validate state if (isset($newState['count']) && $newState['count'] < 0) { throw new \InvalidArgumentException('Count cannot be negative'); } return $newState; }, // Logging middleware function(callable $updater, array $state) { $start = microtime(true); $newState = $updater($state); $duration = microtime(true) - $start; Log::debug('State update completed', [ 'duration' => $duration, 'changes' => array_diff_assoc($newState, $state) ]); return $newState; }, // Analytics middleware function(callable $updater, array $state) { $newState = $updater($state); if (app()->environment('production')) { Analytics::track('state_updated', [ 'store' => static::class, 'changes' => array_keys(array_diff_assoc($newState, $state)) ]); } return $newState; } ]; }
Automatic onUpdate() Handling
The onUpdate() method is called automatically after every state change:
protected function onUpdate(array $previousState, array $newState): void { // Example 1: Automatic syncing with database if (isset($newState['user']) && $newState['user'] !== $previousState['user']) { // Sync user preferences to database DB::table('user_preferences')->updateOrCreate( ['user_id' => $newState['user']['id']], ['preferences' => json_encode($newState['preferences'])] ); } // Example 2: Real-time broadcasting if (isset($newState['cart_items']) && $newState['cart_items'] !== $previousState['cart_items']) { broadcast(new CartUpdated($newState['cart_items'])); } // Example 3: Cache invalidation if (isset($newState['settings'])) { Cache::forget('user_settings_' . auth()->id()); } // Example 4: Audit logging AuditLog::create([ 'event' => 'state_update', 'store' => static::class, 'old_state' => $previousState, 'new_state' => $newState, 'changes' => array_diff_assoc($newState, $previousState) ]); }
๐๏ธ Store Examples
Shopping Cart Store with Lifecycle Hooks
php artisan make:store Cart
<?php namespace App\Stores; use Roddy\StateForge\Stores\BaseStore; class CartStore extends BaseStore { protected string $persistenceType = 'file'; protected function initializeState(): array { return [ 'items' => [], 'total' => 0, 'item_count' => 0, 'coupon' => null, 'discount' => 0, 'created_at' => now()->toISOString(), 'addItem' => function ($productId, $name, $price, $quantity = 1) { $this->setState(function($state) use ($productId, $name, $price, $quantity) { $items = $state['items']; $existingIndex = $this->findItemIndex($items, $productId); if ($existingIndex !== -1) { $items[$existingIndex]['quantity'] += $quantity; } else { $items[] = [ 'id' => $productId, 'name' => $name, 'price' => $price, 'quantity' => $quantity, 'added_at' => now()->toISOString() ]; } return $this->calculateCartTotals(array_merge($state, [ 'items' => $items, 'updated_at' => now()->toISOString() ])); }); }, 'removeItem' => function ($productId) { $this->setState(function($state) use ($productId) { $items = array_filter($state['items'], fn($item) => $item['id'] !== $productId); return $this->calculateCartTotals(array_merge($state, [ 'items' => array_values($items), 'updated_at' => now()->toISOString() ])); }); }, 'applyCoupon' => function ($code) { $this->setState(function($state) use ($code) { $coupon = \App\Models\Coupon::where('code', $code)->valid()->first(); $discount = $coupon ? $coupon->calculateDiscount($state['total']) : 0; return array_merge($state, [ 'coupon' => $coupon, 'discount' => $discount, 'updated_at' => now()->toISOString() ]); }); }, 'clearCart' => function () { $this->setState(fn($state) => array_merge($state, [ 'items' => [], 'total' => 0, 'item_count' => 0, 'coupon' => null, 'discount' => 0, 'updated_at' => now()->toISOString() ])); }, 'getSummary' => function () { return [ 'item_count' => $this->item_count, 'total' => $this->total, 'discount' => $this->discount, 'final_total' => $this->total - $this->discount, 'items' => $this->items, 'persistence' => $this->getPersistenceType() ]; } ]; } protected function middlewares(): array { return [ // Validate cart items function(callable $updater, array $state) { $newState = $updater($state); // Ensure quantities are positive foreach ($newState['items'] as $item) { if ($item['quantity'] < 1) { throw new \InvalidArgumentException('Item quantity must be at least 1'); } } return $newState; } ]; } protected function onUpdate(array $previousState, array $newState): void { // Log cart changes \Log::info('Cart updated', [ 'previous_items' => count($previousState['items']), 'new_items' => count($newState['items']), 'total_change' => $newState['total'] - $previousState['total'] ]); // Broadcast real-time updates if ($newState['items'] !== $previousState['items']) { broadcast(new \App\Events\CartUpdated( auth()->user(), $newState['items'], $newState['total'] )); } // Sync to database if user is logged in if (auth()->check() && $newState['items'] !== $previousState['items']) { \App\Jobs\SyncCartToDatabase::dispatch( auth()->id(), $newState['items'] ); } } private function findItemIndex(array $items, $productId): int { foreach ($items as $index => $item) { if ($item['id'] === $productId) { return $index; } } return -1; } private function calculateCartTotals(array $state): array { $items = $state['items']; $item_count = array_sum(array_column($items, 'quantity')); $subtotal = array_sum(array_map(fn($item) => $item['price'] * $item['quantity'], $items)); return array_merge($state, [ 'item_count' => $item_count, 'subtotal' => $subtotal, 'total' => $subtotal - ($state['discount'] ?? 0) ]); } }
User Preferences Store with Automatic Syncing
php artisan make:store UserPreferences
<?php namespace App\Stores; use Roddy\StateForge\Stores\BaseStore; class UserPreferencesStore extends BaseStore { protected string $persistenceType = 'cache'; protected function initializeState(): array { return [ 'theme' => 'light', 'language' => 'en', 'notifications' => true, 'font_size' => 'medium', 'timezone' => 'UTC', 'created_at' => now()->toISOString(), 'setTheme' => function (string $theme) { $this->setState(fn($state) => array_merge($state, [ 'theme' => $theme, 'updated_at' => now()->toISOString() ])); }, 'setLanguage' => function (string $language) { $this->setState(fn($state) => array_merge($state, [ 'language' => $language, 'updated_at' => now()->toISOString() ])); }, 'toggleNotifications' => function () { $this->setState(fn($state) => array_merge($state, [ 'notifications' => !$state['notifications'], 'updated_at' => now()->toISOString() ])); }, 'updateAll' => function (array $preferences) { $this->setState(fn($state) => array_merge($state, [ ...$preferences, 'updated_at' => now()->toISOString() ])); }, 'getPreferences' => function () { return [ 'theme' => $this->theme, 'language' => $this->language, 'notifications' => $this->notifications, 'font_size' => $this->font_size, 'timezone' => $this->timezone, 'persistence' => $this->getPersistenceType() ]; } ]; } protected function middlewares(): array { return [ // Validate preferences function(callable $updater, array $state) { $newState = $updater($state); $allowedThemes = ['light', 'dark', 'system']; $allowedLanguages = ['en', 'es', 'fr', 'de']; $allowedFontSizes = ['small', 'medium', 'large']; if (isset($newState['theme']) && !in_array($newState['theme'], $allowedThemes)) { throw new \InvalidArgumentException("Invalid theme: {$newState['theme']}"); } if (isset($newState['language']) && !in_array($newState['language'], $allowedLanguages)) { throw new \InvalidArgumentException("Invalid language: {$newState['language']}"); } if (isset($newState['font_size']) && !in_array($newState['font_size'], $allowedFontSizes)) { throw new \InvalidArgumentException("Invalid font size: {$newState['font_size']}"); } return $newState; } ]; } protected function onUpdate(array $previousState, array $newState): void { // Sync to database when user is logged in if (auth()->check()) { \App\Jobs\SyncPreferencesToDatabase::dispatch( auth()->id(), $newState ); } // Update session based on preferences if ($newState['theme'] !== $previousState['theme']) { session(['theme' => $newState['theme']]); } if ($newState['language'] !== $previousState['language']) { session(['locale' => $newState['language']]); app()->setLocale($newState['language']); } // Log preference changes \Log::info('User preferences updated', [ 'user_id' => auth()->id(), 'changes' => array_diff_assoc($newState, $previousState) ]); } }
๐ API Reference
StateForge Facade
use Roddy\StateForge\Facades\StateForge; use App\Stores\CounterStore; // Get or create store (auto-discovered) $store = StateForge::get(CounterStore::class); // Create store with custom configuration $store = StateForge::create(CounterStore::class, [ 'persistence' => 'cache', 'cache_ttl' => 3600 ]); // Store management StateForge::all(); // Get all stores StateForge::exists(CounterStore::class); // Check if store exists StateForge::reset(CounterStore::class); // Reset specific store StateForge::reset(); // Reset all stores StateForge::getStoreInfo(); // Get store information StateForge::getClientId(); // Get client identifier // Change persistence at runtime StateForge::setPersistence(CounterStore::class, 'file');
Store Instance Methods
$counter = StateForge::get(CounterStore::class); // or useStore(CounterStore::class) // Access state properties echo $counter->count; echo $counter->created_at; // Call store methods $counter->increment(5); $counter->decrement(2); $counter->resetState(); // Get state $state = $counter->getState(); // Hooks $counter->before('method', $callback); $counter->after('method', $callback); // Events $counter->on('event', $listener); $counter->off('event', $listener); // Lifecycle info $persistence = $counter->getPersistenceType(); // 'file', 'cache', 'session', 'none' $hooks = $counter->getHookInfo(); // Get hook information $listeners = $counter->getEventListeners(); // Get event listeners
๐พ Persistence Options
File Persistence (Default)
protected string $persistenceType = 'file';
- Survives: Browser restarts, system reboots
- Storage: JSON files in
storage/app/private/stateforge - Best for: Long-term data, user preferences, shopping carts
Cache Persistence
protected string $persistenceType = 'cache';
- Survives: Browser restarts (with TTL)
- Storage: Laravel cache (Redis, Memcached, etc.)
- Best for: Temporary data, session-like data
Session Persistence
protected string $persistenceType = 'session';
- Survives: Page refreshes only
- Storage: Laravel session
- Best for: Flash data, temporary calculations
No Persistence
protected string $persistenceType = 'none';
- Survives: Current request only
- Storage: Memory
- Best for: Volatile data, calculations
โ๏ธ Configuration
Default Configuration (config/stateforge.php)
return [ 'default' => [ 'persistence' => 'file', 'auto_persist' => true, ], 'persistence' => [ 'file' => [ 'path' => storage_path('app/private/stateforge'), 'auto_cleanup' => true, 'cleanup_after_days' => 30, ], 'cache' => [ 'driver' => null, // Use default cache driver 'prefix' => 'stateforge', 'ttl' => 3600 * 24 * 30, // 30 days ], 'session' => [ 'prefix' => 'stateforge', ], ], 'client' => [ 'cookie_name' => 'stateforge_client_id', 'cookie_lifetime' => 60 * 24 * 365, // 1 year 'cleanup_after_days' => 30, ], 'auto_discovery' => [ 'enabled' => true, 'path' => app_path('Stores'), ], ];
๐ Advanced Usage
Using in Livewire Components
<?php namespace App\Livewire; use Livewire\Component; use Roddy\StateForge\Facades\StateForge; use App\Stores\CounterStore; class CounterComponent extends Component { public $count = 0; protected $counter; public function mount() { $this->counter = StateForge::get(CounterStore::class); // or useStore(CounterStore::class) $this->count = $this->counter->count; // onUpdate() will handle automatic syncing } public function increment() { $this->counter->increment(); $this->count = $this->counter->count; } public function render() { return view('livewire.counter-component'); } }
Using in Blade Views
@php $counter = useStore(\App\Stores\CounterStore::class); $cart = useStore(\App\Stores\CartStore::class); @endphp <div class="dashboard"> <div class="counter"> <h3>Count: {{ $counter->count }}</h3> <button onclick="incrementCounter()">+</button> </div> <div class="cart"> <h4>Cart Items: {{ $cart->item_count }}</h4> <p>Total: ${{ number_format($cart->total, 2) }}</p> </div> </div> <script> function incrementCounter() { fetch('/counter/increment', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' } }); } </script>
Custom Middleware Classes
<?php namespace App\StateForge\Middlewares; use Roddy\StateForge\Contracts\Middleware; class ValidationMiddleware implements Middleware { public function __invoke(callable $updater, array $state): array { $newState = $updater($state); // Add validation logic if (isset($newState['count']) && $newState['count'] < 0) { throw new \InvalidArgumentException('Count cannot be negative'); } return $newState; } } class AnalyticsMiddleware implements Middleware { public function __invoke(callable $updater, array $state): array { $newState = $updater($state); // Track state changes if (app()->environment('production')) { \App\Jobs\TrackStateChange::dispatch( get_class($this), array_keys(array_diff_assoc($newState, $state)) ); } return $newState; } } // Usage in store protected function middlewares(): array { return [ \App\StateForge\Middlewares\ValidationMiddleware::class, \App\StateForge\Middlewares\AnalyticsMiddleware::class, ]; }
๐งน Maintenance
Cleanup Commands
# Clean up stores for clients not seen in 30 days (default) php artisan stateforge:cleanup # Clean up stores for clients not seen in 60 days php artisan stateforge:cleanup --days=60 # Schedule cleanup (in app/Console/Kernel.php) $schedule->command('stateforge:cleanup --days=30')->daily();
Store Creation
# Create a new store php artisan make:store ProductCatalog # Stores are automatically discovered from app/Stores/
๐ Best Practices
Store Design
- Keep stores focused on a single responsibility
- Use descriptive method names
- Leverage
onUpdate()for side effects - Add validation in middlewares
Performance
- Choose appropriate persistence type
- Use cache persistence for frequently accessed data
- Clean up old stores regularly
- Monitor store sizes
Security
- Never store sensitive data without encryption
- Validate all inputs in middlewares
- Use appropriate persistence for data sensitivity
๐ Troubleshooting
Store returns null
// โ Wrong - might create new instance $store = new CounterStore(); // โ Correct - use facade $store = StateForge::get(CounterStore::class);
Hooks not working
- Ensure you're using the same store instance
- Check that hooks are added before calling methods
- Verify method signatures match
Persistence issues
- Check storage permissions for file persistence
- Verify cache configuration for cache persistence
- Ensure session is configured for session persistence
โ FAQ
How does user isolation work?
StateForge uses a combination of:
- Persistent Cookie: Survives browser restarts
- Browser Fingerprinting: Fallback using user agent, language, etc.
- Client ID: Unique identifier per browser/user combination
Can I use StateForge with SPAs?
Yes! StateForge works perfectly with SPAs. Use the API endpoints to interact with stores from your frontend.
How is data persisted?
Only state data is persisted - methods (closures) are automatically reattached when stores are loaded.
Can I share stores between users?
No, stores are intentionally isolated per user. For shared state, use your database or cache directly.
What's the performance impact?
Minimal. StateForge is optimized for performance with lazy loading and efficient persistence.
Can I use multiple persistence types for different stores?
Yes! Each store can have its own persistence configuration.
๐ License
StateForge is open-sourced software licensed under the MIT license.
๐ค Contributing
We welcome contributions! Please see our Contributing Guide for details.
Development Setup
# Clone the repository
git clone https://github.com/FreddyWhest/stateforge.git
๐ License
StateForge is open-sourced software licensed under the MIT license.
๐ Acknowledgments
- Inspired by modern state management libraries like Zustand and Redux
- Built with the Laravel community in mind
- Thanks to all contributors and users
StateForge - Forge your application state with elegance and power! ๐ฅ
For more information, visit the GitHub repository or join our community.