Service vs Action Classes

Learn when to use Service classes vs Action classes

As applications grow, controllers become bloated with business logic. The solution is to extract this logic into dedicated classes.

Two Popular Approaches:

1. Service Classes: Group related business logic
Example: UserService has createUser(), updateProfile(), deleteAccount()

2. Action Classes: Single-purpose, single-method classes
Example: CreateUserAction, UpdateUserProfileAction, DeleteUserAccountAction

When to Use Service Classes:
• Related operations that share dependencies
• When operations are simple and numerous
• Legacy codebases being refactored

When to Use Action Classes:
• Complex operations with unique dependencies
• When following Single Responsibility Principle strictly
• Modern, maintainable codebases
• When each operation has distinct validation/logic

Real-World Example:
User management in a SaaS platform with signup, onboarding, profile updates, subscription changes, and account deletion. Each operation is complex enough to deserve its own class.

Code Examples

Bloated Controller (No Extraction)
✗ Avoid This php
<?php
// BAD: All business logic in controller
class UserController extends Controller
{
    public function store(Request $request)
    {
        // Validation
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8|confirmed',
            'company' => 'required|string',
        ]);
        
        // Create user
        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => bcrypt($validated['password']),
        ]);
        
        // Create company
        $company = Company::create([
            'name' => $validated['company'],
            'owner_id' => $user->id,
        ]);
        
        // Attach user to company
        $user->companies()->attach($company->id, [
            'role' => 'owner',
            'joined_at' => now(),
        ]);
        
        // Create default team
        $team = Team::create([
            'company_id' => $company->id,
            'name' => 'Default Team',
        ]);
        
        // Add user to team
        $team->members()->attach($user->id);
        
        // Send welcome email
        Mail::to($user->email)->send(new WelcomeEmail($user));
        
        // Create CRM contact
        Http::post('https://crm.api.com/contacts', [
            'email' => $user->email,
            'name' => $user->name,
            'company' => $company->name,
        ]);
        
        // Track analytics
        Analytics::track('user_signed_up', [
            'user_id' => $user->id,
            'plan' => 'free',
        ]);
        
        // Generate API token
        $token = $user->createToken('auth_token')->plainTextToken;
        
        return response()->json([
            'user' => $user,
            'company' => $company,
            'token' => $token,
        ], 201);
    }
}

// Problems:
// ❌ Controller is 50+ lines for one action
// ❌ Hard to test
// ❌ Logic can't be reused (CLI, API, etc.)
// ❌ Violates Single Responsibility
// ❌ Difficult to maintain

💡 The controller has too many responsibilities: validation, user creation, company setup, team management, email sending, API calls, and more. It's impossible to test in isolation and can't be reused.

Service Class Approach
✓ Best Practice php
<?php
// Service class groups related operations
namespace App\Services;

use App\Models\User;
use App\Models\Company;
use App\Models\Team;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

class UserService
{
    public function createUser(array $data): User
    {
        return DB::transaction(function () use ($data) {
            // Create user
            $user = User::create([
                'name' => $data['name'],
                'email' => $data['email'],
                'password' => bcrypt($data['password']),
            ]);
            
            // Create company
            $company = Company::create([
                'name' => $data['company'],
                'owner_id' => $user->id,
            ]);
            
            // Attach relationships
            $user->companies()->attach($company->id, [
                'role' => 'owner',
                'joined_at' => now(),
            ]);
            
            // Create default team
            $team = Team::create([
                'company_id' => $company->id,
                'name' => 'Default Team',
            ]);
            
            $team->members()->attach($user->id);
            
            return $user;
        });
    }
    
    public function sendWelcomeEmail(User $user): void
    {
        Mail::to($user->email)->send(new WelcomeEmail($user));
    }
    
    public function updateProfile(User $user, array $data): User
    {
        $user->update($data);
        return $user->fresh();
    }
    
    public function deleteAccount(User $user): void
    {
        DB::transaction(function () use ($user) {
            $user->companies()->detach();
            $user->tokens()->delete();
            $user->delete();
        });
    }
}

// Controller becomes much simpler
class UserController extends Controller
{
    public function __construct(
        protected UserService $userService
    ) {}
    
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8|confirmed',
            'company' => 'required|string',
        ]);
        
        $user = $this->userService->createUser($validated);
        $this->userService->sendWelcomeEmail($user);
        
        $token = $user->createToken('auth_token')->plainTextToken;
        
        return response()->json([
            'user' => $user,
            'token' => $token,
        ], 201);
    }
}

// Benefits:
// ✅ Controller is clean
// ✅ Logic is reusable
// ✅ Easier to test
// ⚠️  Service class might grow large over time

💡 The Service class extracts business logic from the controller. Multiple related operations live in one class. The controller is now thin and focused on HTTP concerns.

Action Class Approach (Best for Complex Apps)
💡 Solution php
<?php
// Single-purpose Action classes
namespace App\Actions\User;

use App\Models\User;
use App\Models\Company;
use App\Models\Team;
use Illuminate\Support\Facades\DB;

class CreateUserAction
{
    public function execute(array $data): User
    {
        return DB::transaction(function () use ($data) {
            $user = User::create([
                'name' => $data['name'],
                'email' => $data['email'],
                'password' => bcrypt($data['password']),
            ]);
            
            $company = Company::create([
                'name' => $data['company'],
                'owner_id' => $user->id,
            ]);
            
            $user->companies()->attach($company->id, [
                'role' => 'owner',
                'joined_at' => now(),
            ]);
            
            $team = Team::create([
                'company_id' => $company->id,
                'name' => 'Default Team',
            ]);
            
            $team->members()->attach($user->id);
            
            return $user;
        });
    }
}

class SendWelcomeEmailAction
{
    public function execute(User $user): void
    {
        Mail::to($user->email)->send(new WelcomeEmail($user));
    }
}

class DeleteUserAccountAction
{
    public function execute(User $user): void
    {
        DB::transaction(function () use ($user) {
            // Detach all relationships
            $user->companies()->detach();
            $user->teams()->detach();
            
            // Delete tokens
            $user->tokens()->delete();
            
            // Soft delete user
            $user->delete();
            
            // Log to audit trail
            AuditLog::create([
                'action' => 'user_deleted',
                'user_id' => $user->id,
            ]);
        });
    }
}

// Controller is extremely clean
class UserController extends Controller
{
    public function store(
        Request $request,
        CreateUserAction $createUser,
        SendWelcomeEmailAction $sendEmail
    ) {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8|confirmed',
            'company' => 'required|string',
        ]);
        
        $user = $createUser->execute($validated);
        $sendEmail->execute($user);
        
        $token = $user->createToken('auth_token')->plainTextToken;
        
        return response()->json([
            'user' => $user,
            'token' => $token,
        ], 201);
    }
    
    public function destroy(User $user, DeleteUserAccountAction $deleteAccount)
    {
        $deleteAccount->execute($user);
        
        return response()->json([
            'message' => 'Account deleted successfully'
        ]);
    }
}

// Benefits:
// ✅ Each class has one job
// ✅ Extremely testable
// ✅ Controller shows exact intent
// ✅ Easy to find logic (naming is clear)
// ✅ Can compose actions together
// ✅ Perfect for complex operations

💡 Action classes follow Single Responsibility Principle. Each action does one thing and does it well. Controllers are incredibly clean and readable. Testing is straightforward. This scales better for large applications.