- 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
Clean Controllers in Laravel: Actions, Services or Form Requests?
About Post
Every Laravel project has one. The controller method that started as six tidy lines and is now a 90-line scroll of validation, permission checks, database writes, emails and a suspicious if that nobody wants to touch.
Nobody writes it on purpose. It grows one "quick fix" at a time, because the controller is always the nearest place to put code. The good news is that Laravel already gives you the tools to keep controllers thin. The confusing part is knowing which tool takes which job.
The mistake: one method doing five jobs
Here's a typical "renew a lease" method. It's not terrible. That's what makes it dangerous, because it will keep growing:
public function renew(Request $request, Lease $lease)
{
$data = $request->validate([
'months' => ['required', 'integer', 'in:6,12,24'],
]);
if ($request->user()->cannot('renew', $lease)) {
abort(403);
}
$renewal = DB::transaction(function () use ($lease, $data) {
$lease->update(['status' => 'renewed']);
return $lease->renewals()->create([
'starts_at' => $lease->ends_at->copy()->addDay(),
'ends_at' => $lease->ends_at->copy()->addMonths($data['months']),
'rent' => $lease->rent,
]);
});
Mail::to($lease->tenant)->queue(new LeaseRenewed($renewal));
return redirect()->route('leases.show', $lease);
}
Count the jobs: validation, authorization, a business operation, a side effect, and an HTTP response. Only the last one is really the controller's business.
Why it hurts
The pain doesn't show up on day one. It shows up when:
- The mobile app needs the same renewal through an API endpoint, and someone copies the method.
- Operations wants a nightly command that auto-renews certain leases, and someone copies it again.
- You want to test the renewal rules, but the only way in is a full HTTP request.
Three copies later, a bug fix lands in one of them and not the others. That's the real cost of a fat controller: not ugliness, but logic you can't reuse.
The fix: give each job a proper home
Validation and authorization go to a Form Request
class RenewLeaseRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('renew', $this->route('lease'));
}
public function rules(): array
{
return ['months' => ['required', 'integer', 'in:6,12,24']];
}
}
Laravel runs both before your controller method is even called. The actual rule ("who may renew this lease?") still lives in a policy, and the form request simply asks it.
The business operation goes to an action
An action is a plain class that does one thing. No base class, no package required:
class RenewLease
{
public function handle(Lease $lease, int $months): Renewal
{
$renewal = DB::transaction(function () use ($lease, $months) {
$lease->update(['status' => 'renewed']);
return $lease->renewals()->create([
'starts_at' => $lease->ends_at->copy()->addDay(),
'ends_at' => $lease->ends_at->copy()->addMonths($months),
'rent' => $lease->rent,
]);
});
Mail::to($lease->tenant)->queue(new LeaseRenewed($renewal));
return $renewal;
}
}
Notice what it doesn't know about: requests, redirects or HTTP. It takes plain values and returns a model. That's what makes it callable from a controller, an API endpoint, an Artisan command or a queued job, and testable without any of them.
Two small details I'd keep: the mail is queued after the transaction commits, so a rollback can't leave the tenant with an email about a renewal that doesn't exist. And copy() matters, because Laravel's date casts are mutable Carbon instances by default; calling addDay() directly would quietly change $lease->ends_at itself.
What's left in the controller is HTTP
class RenewLeaseController extends Controller
{
public function __invoke(
RenewLeaseRequest $request,
Lease $lease,
RenewLease $renew,
): RedirectResponse {
$renew->handle($lease, $request->integer('months'));
return to_route('leases.show', $lease);
}
}
A single-action controller (php artisan make:controller RenewLeaseController --invokable) fits this nicely: one route, one class, one reason to change. You don't have to use them everywhere. A normal resource controller whose methods each call an action works just as well.
Actions, services or the model?
This is where teams argue, so here's the rule I use:
| The code is... | Put it in |
|---|---|
| Input rules and "may this user do this?" | Form Request (calling a Policy) |
| One business operation with a verb name: renew, approve, cancel | An action class |
| A wrapper around an external system, or a group of closely related operations sharing setup | A service class (e.g. a payment gateway client) |
Small facts about one record: isExpired(), relationships, scopes, casts | The model |
| Reactions that other parts of the app care about | Events and queued listeners or jobs |
| Turning a request into a response | The controller |
The trap on the other side is the fat model. Moving everything out of the controller and into Lease.php just gives you a 2,000-line model instead. Models are great for facts about a record. They're a poor home for workflows that touch five other tables and send emails.
And the trap with services is the LeaseService that slowly collects every lease-related method in the app. If your service has twenty unrelated public methods, it's a fat controller wearing a different hat. Splitting it into actions usually makes each one easier to find and to test.
The quick test: could you call this logic from an Artisan command without faking a request? If not, it's in the wrong place.
Don't overdo it
Not every endpoint needs three classes. A controller method that loads a list with a scope and returns a view is fine as it is. I reach for actions when an operation has real rules, touches several tables, or will obviously be needed from somewhere else. Structure should follow complexity, not run ahead of it.
The checklist
- Controllers handle HTTP: take the request, call something, return a response.
- Validation and authorization live in Form Requests and Policies.
- Business operations live in small, verb-named action classes.
- Services wrap external systems; models hold facts about a record.
- Start simple, extract when the second caller shows up.
The Form Request docs are a good place to start if you haven't used them much.
Where does business logic live in your Laravel apps: actions, services, models, or something else entirely?

Be first to comment it...