- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
State Machines for Business Workflows: Stop Letting Any Code Set Any Status
About Post
Somewhere in your database there's a record that shouldn't exist. A contract that is terminated but was signed after it was terminated. An order that is refunded but never paid. An invoice that went from draft straight to overdue.
Nobody wrote code to do that on purpose. It happened because the status column was a free-for-all: any part of the app could set it to any value at any time.
The fix has a slightly intimidating name, a state machine, and a reassuringly small amount of code.
The mistake: status as a plain string
Most business workflows start life like this:
$contract->status = 'active';
$contract->save();
It's fine on day one. By month six, that line exists in a controller, an Artisan command, a queued job, an admin panel action and a webhook handler. Each was written by someone who knew their part of the workflow and not the others.
So the rules of the workflow ("you can't activate a contract that hasn't been signed") live nowhere. Or worse, they live in five places, slightly differently.
What a state machine actually is
Strip away the computer science and a state machine is three things:
- A fixed list of states. A contract can be draft, pending signature, active, expired or terminated. Nothing else.
- A list of allowed transitions. Draft can go to pending signature. Pending signature can go to active or back to draft. Active can go to expired or terminated. Expired and terminated go nowhere.
- One gate that every change passes through. If a transition isn't on the list, it doesn't happen.
Think of a board game. The board tells you which squares connect. You can't move from square 3 to square 40 just because you'd like to be there.
Drawn out, the contract workflow is:
draft ──► pending_signature ──► active ──► expired
▲ │ │
└───────────────┘ └──► terminated
That diagram is worth pasting into the pull request description, too. Product people can read it, and they will spot the missing arrow you didn't think of.
Step 1: an enum for the states
PHP enums are a natural home for this, because the states and the transition rules can live together:
enum ContractStatus: string
{
case Draft = 'draft';
case PendingSignature = 'pending_signature';
case Active = 'active';
case Expired = 'expired';
case Terminated = 'terminated';
/** @return list<self> */
public function allowedNext(): array
{
return match ($this) {
self::Draft => [self::PendingSignature],
self::PendingSignature => [self::Active, self::Draft],
self::Active => [self::Expired, self::Terminated],
self::Expired, self::Terminated => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedNext(), true);
}
}
The match has no default arm on purpose. If someone adds a new case and forgets to define its transitions, PHP throws an UnhandledMatchError the first time it's hit, instead of silently allowing everything.
Cast the column on the model so you never handle raw strings again:
protected function casts(): array
{
return ['status' => ContractStatus::class];
}
Step 2: one gate for every change
Now give the model a single method that changes the status, and make it the only way in:
public function transitionTo(ContractStatus $next, ?string $reason = null): void
{
DB::transaction(function () use ($next, $reason) {
$fresh = static::whereKey($this->id)->lockForUpdate()->firstOrFail();
if (! $fresh->status->canTransitionTo($next)) {
throw new InvalidTransition($fresh->status, $next);
}
$this->history()->create([
'from' => $fresh->status, 'to' => $next, 'reason' => $reason,
'user_id' => auth()->id(),
]);
$this->forceFill(['status' => $next])->save();
});
}
Three details do the heavy lifting here:
- The row lock. Two requests trying to activate and terminate the same contract at the same moment are processed one after the other, and the second one checks against the real current state, not a stale copy.
- The history row. Every transition records who, when, from what, to what and why. When someone asks "why is this contract terminated?", you have an answer instead of a shrug.
- The exception. An illegal transition is a bug or a misuse, so it should be loud.
Simplified: a real version would also fire events, keep other unsaved changes on the model out of this save, and decide what happens when the lock waits too long.
Step 3: guards for the business rules
"Is this transition on the map?" is only half the question. The other half is "are the conditions met right now?" A contract can move from pending signature to active, but only if every party has signed.
Those checks are guards. Keep them next to the transition, not scattered in controllers:
public function activate(): void
{
if ($this->signatures()->whereNull('signed_at')->exists()) {
throw new GuardFailed('All parties must sign before activation.');
}
$this->transitionTo(ContractStatus::Active);
}
Now the controller just calls $contract->activate(). The queued job that processes e-signature callbacks calls the same method. The rule exists exactly once.
The rule: nothing outside the model is allowed to write the status column directly. If you can grep your codebase for 'status' => and find assignments outside the gate, the state machine has a leak.
Side effects belong after the transition
Activating a contract usually means more than changing a column: send a confirmation, generate the first rent schedule, notify the tenant app. Put those in event listeners or queued jobs triggered after the transition succeeds, not before.
The order matters. If you send the "your contract is active" email and then the transition fails a guard, you've told someone something untrue. Transition first, then react.
Do you need a package?
There are good state machine packages for Laravel, and for complex workflows with many states, nested states or configurable transitions they're worth a look. For most business entities, though, an enum, a gate method and a history table cover it. It's a small amount of code you fully understand, and that's a feature.
Signs you've outgrown the simple version: transitions that need different permissions per role, workflows that admins configure without a deploy, or the same entity type following different workflows per customer.
Checklist
- States are an enum, cast on the model.
- Allowed transitions live in one place, next to the states.
- One method changes the status, with a row lock.
- Guards check business conditions before the transition.
- Every transition writes a history row.
- Side effects run after a successful transition.
What's the workflow in your app that most needs this? I'd bet it's the one where support regularly asks "how did this record end up in this state?"

Be first to comment it...