Skip to main content

Chorus Actions

Chorus Actions are the core of Laravel Chorus’s write operations system. They provide a clean, powerful way to handle complex multi-table operations with built-in validation, offline support, and real-time synchronization.

Core Concepts

What is a Chorus Action?

A Chorus Action is a server-side class that:
  • Handles multiple operations atomically across different tables
  • Validates data using operation-specific validation rules
  • Supports offline writes automatically with queue and sync
  • Enables optimistic updates for instant UI feedback
  • Generates TypeScript functions for type-safe client usage

Key Features

  • Multi-table operations - Create, update, delete across multiple tables in one action
  • Automatic offline support - No configuration needed
  • Operation validation - Table.operation-specific validation rules
  • Type generation - Auto-generated TypeScript functions
  • Single operation shorthand - Simplified API for simple actions
  • Optimistic updates - Instant UI feedback

Creating Chorus Actions

Generate with Artisan

php artisan chorus:make-action CreateMessageWithActivityAction
This creates a new action in app/Actions/ChorusActions/.

Basic Structure

<?php

namespace App\Actions\ChorusActions;

use App\Models\Message;
use App\Models\User;
use Illuminate\Http\Request;
use Pixelsprout\LaravelChorus\Support\ChorusAction;

final class CreateMessageWithActivityAction extends ChorusAction
{
    public function rules(): array
    {
        return [
            'messages.create' => [
                'body' => 'required|string|max:1000',
                'platform_id' => 'required|uuid|exists:platforms,id',
            ],
            'users.update' => [
                'id' => 'required|uuid|exists:users,id',
                'last_activity_at' => 'required|date',
            ],
        ];
    }

    public function handle(Request $request): void
    {
        $user = auth()->user();

        if (!$user) {
            throw new \Exception('User must be authenticated');
        }

        // Get create operations for messages
        $messageCreateOperations = $this->getOperations('messages', 'create');
        
        foreach ($messageCreateOperations as $messageData) {
            Message::create([
                'id' => $messageData['id'] ?? Str::uuid(),
                'body' => $messageData['body'],
                'platform_id' => $messageData['platform_id'],
                'user_id' => $user->id,
                'tenant_id' => $user->tenant_id,
            ]);
        }

        // Update user activity
        User::where('id', $user->id)->update([
            'last_activity_at' => now(),
        ]);
    }
}

Validation System

Operation-Specific Rules

The rules() method uses a table.operation format for precise validation:
public function rules(): array
{
    return [
        'messages.create' => [
            'body' => 'required|string|max:1000',
            'platform_id' => 'required|uuid|exists:platforms,id',
        ],
        'messages.update' => [
            'id' => 'required|uuid|exists:messages,id',
            'body' => 'required|string|max:1000',
        ],
        'users.update' => [
            'id' => 'required|uuid|exists:users,id',
            'last_activity_at' => 'required|date',
        ],
    ];
}

Available Operations

  • create - Insert new records
  • update - Modify existing records
  • delete - Remove records

Automatic UUID Generation

Chorus automatically generates UUIDs for create operations when the id field is missing:
// UUID automatically generated
create('messages', {
    body: "Hello world",
    user_id: 123,
});

// You can still provide your own ID if needed
create('messages', {
    id: "custom-id-123",
    body: "Hello world", 
    user_id: 123,
});

Handling Operation Data

Chorus Actions use the getOperations() method to retrieve operation data from the request:
public function handle(Request $request): void
{
    $user = auth()->user();
    
    // Get create operations for messages
    $messageCreateOperations = $this->getOperations('messages', 'create');
    
    foreach ($messageCreateOperations as $messageData) {
        Message::create([
            'id' => $messageData['id'] ?? Str::uuid(),
            'body' => $messageData['body'],
            'user_id' => $user->id,
        ]);
    }

    // Get update operations for users
    $userUpdateOperations = $this->getOperations('users', 'update');
    
    foreach ($userUpdateOperations as $userData) {
        User::where('id', $userData['id'])->update($userData);
    }

    // Get delete operations for comments
    $commentDeleteOperations = $this->getOperations('comments', 'delete');
    
    foreach ($commentDeleteOperations as $commentData) {
        Comment::where('id', $commentData['id'])->delete();
    }
}

The getOperations() Method

The getOperations(string $table, string $operation) method returns an array of operation data:
// Returns array of data for creating messages
$creates = $this->getOperations('messages', 'create');

// Returns array of data for updating users  
$updates = $this->getOperations('users', 'update');

// Returns array of data for deleting posts
$deletes = $this->getOperations('posts', 'delete');

Handling Empty Operations

Always check if operations exist before processing:
public function handle(Request $request): void
{
    $messageCreateOperations = $this->getOperations('messages', 'create');
    
    if (empty($messageCreateOperations)) {
        throw new \Exception('No message data found in request');
    }
    
    foreach ($messageCreateOperations as $messageData) {
        // Process each message...
    }
}

Authorization and Validation

Validate data and check permissions within your operations:
public function handle(Request $request): void
{
    $user = auth()->user();
    
    $messageUpdateOperations = $this->getOperations('messages', 'update');
    
    foreach ($messageUpdateOperations as $messageData) {
        // Verify the message belongs to the user's tenant
        $message = Message::where('id', $messageData['id'])
            ->where('tenant_id', $user->tenant_id)
            ->first();

        if (!$message) {
            throw new \Exception('Message not found or unauthorized');
        }

        // Update the message
        $message->update([
            'body' => $messageData['body'],
        ]);
    }
}

Single Operation Shorthand

For actions with only one operation rule, clients can use simplified data format:

Server-Side (Single Operation)

final class SimpleCreateMessageAction extends ChorusAction
{
    public function rules(): array
    {
        return [
            'messages.create' => [
                'body' => 'required|string|max:1000',
                'platform_id' => 'required|uuid|exists:platforms,id',
            ],
        ];
    }

    public function handle(Request $request): void
    {
        // Get the message create operations
        $messageCreateOperations = $this->getOperations('messages', 'create');
        
        foreach ($messageCreateOperations as $messageData) {
            Message::create([
                'id' => $messageData['id'] ?? Str::uuid(),
                'body' => $messageData['body'],
                'platform_id' => $messageData['platform_id'],
                'user_id' => auth()->id(),
            ]);
        }
    }
}

Client-Side Usage

// Full format (always works) - UUID auto-generated
await createMessageAction(({ create }) => {
    create('messages', {
        body: "Hello world",
        platform_id: "abc-123"
    });
});

// Shorthand format (single operation only) - UUID auto-generated
await createMessageAction({
    body: "Hello world",
    platform_id: "abc-123"
});

Route Registration

Important: Chorus Actions must be registered as routes to be accessible from the client-side. Add your action routes to routes/web.php with proper authentication:

Basic Route Registration

// routes/web.php
use App\Actions\ChorusActions\CreateMessageAction;
use App\Actions\ChorusActions\UpdateMessageAction;
use App\Actions\ChorusActions\DeleteMessageAction;

// Chorus Actions - must be authenticated
Route::middleware(['auth'])->group(function () {
    Route::post('/api/actions/create-message', CreateMessageAction::class)->name('chorus.create-message');
    Route::post('/api/actions/update-message', UpdateMessageAction::class)->name('chorus.update-message');
    Route::post('/api/actions/delete-message', DeleteMessageAction::class)->name('chorus.delete-message');
});

Route Naming Convention

The route path should match the action name used in the generated TypeScript functions:
  • Action Class: CreateMessageAction
  • Route Path: /api/actions/create-message
  • Generated Function: createMessageAction()
  • Route Name: chorus.create-message (optional but recommended)

Authentication & Authorization

Always protect Chorus Action routes with appropriate middleware:
// Basic authentication
Route::middleware(['auth'])->group(function () {
    Route::post('/api/actions/create-user', CreateUserAction::class);
});

// Additional middleware for admin actions
Route::middleware(['auth', 'admin'])->group(function () {
    Route::post('/api/actions/delete-user', DeleteUserAction::class);
});

// API-specific middleware
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
    Route::post('/api/actions/bulk-import', BulkImportAction::class);
});

Multi-Tenancy Support

For multi-tenant applications, you can add tenant-specific middleware:
Route::middleware(['auth', 'tenant'])->group(function () {
    Route::post('/api/actions/create-message', CreateMessageAction::class);
    Route::post('/api/actions/update-message', UpdateMessageAction::class);
});

Client-Side Integration

Generated Functions

Chorus automatically generates TypeScript functions for each action:
// Auto-generated in resources/js/_generated/chorus-actions.ts
export async function createMessageWithActivityAction(
  callback: ({ create, update, remove }: ChorusActionMethods) => void
): Promise<ChorusActionResponse>;

React Example

import { createMessageWithActivityAction } from '@/_generated/chorus-actions';

export default function CreateMessageForm() {
    const handleSubmit = async (formData) => {
        const result = await createMessageWithActivityAction(({ create, update }) => {
            // UUID automatically generated for create operations
            create('messages', {
                body: formData.message,
                platform_id: formData.platformId,
                user_id: user.id,
                tenant_id: user.tenant_id,
            });
            
            update('users', {
                id: user.id,
                last_activity_at: new Date().toISOString(),
            });
        });
        
        if (result.success) {
            console.log('Message created successfully!');
        }
    };
}

Vue Example

<script setup>
import { createMessageWithActivityAction } from '@/_generated/chorus-actions';

const handleSubmit = async (formData) => {
    const result = await createMessageWithActivityAction(({ create, update }) => {
        // UUID automatically generated for create operations
        create('messages', {
            body: formData.message,
            platform_id: formData.platformId,
            user_id: user.id,
        });
        
        update('users', {
            id: user.id,
            last_activity_at: new Date().toISOString(),
        });
    });
};
</script>

Advanced Patterns

Authorization

public function handle(Request $request): void
{
    // Check permissions before operations
    if (!auth()->user()->can('create', Message::class)) {
        throw new UnauthorizedException('Cannot create messages');
    }
    
    if (!$this->canUpdateUser(auth()->id())) {
        throw new UnauthorizedException('Cannot update user data');
    }
    
    // Proceed with operations...
    $messageCreateOperations = $this->getOperations('messages', 'create');
    
    foreach ($messageCreateOperations as $messageData) {
        Message::create($messageData);
    }
}

Database Transactions

public function handle(Request $request): void
{
    DB::transaction(function () use ($request) {
        // All operations in a single transaction
        $messageCreateOperations = $this->getOperations('messages', 'create');
        foreach ($messageCreateOperations as $messageData) {
            Message::create($messageData);
        }
        
        $userUpdateOperations = $this->getOperations('users', 'update');
        foreach ($userUpdateOperations as $userData) {
            User::where('id', $userData['id'])->update($userData);
        }
        
        $platformUpdateOperations = $this->getOperations('platforms', 'update');
        foreach ($platformUpdateOperations as $platformData) {
            Platform::where('id', $platformData['id'])->update($platformData);
        }
    });
}

Complex Business Logic

public function handle(Request $request): void
{
    $user = auth()->user();
    
    // Get message create operations
    $messageCreateOperations = $this->getOperations('messages', 'create');
    
    foreach ($messageCreateOperations as $messageData) {
        // Create the message
        $message = Message::create([
            'id' => $messageData['id'] ?? Str::uuid(),
            'body' => $messageData['body'],
            'user_id' => $user->id,
            'platform_id' => $messageData['platform_id'],
        ]);
        
        // Update user stats based on message content
        $wordCount = str_word_count($messageData['body']);
        User::where('id', $user->id)->update([
            'total_words' => DB::raw("total_words + {$wordCount}"),
            'message_count' => DB::raw('message_count + 1'),
            'last_activity_at' => now(),
        ]);
        
        // Award achievements
        $updatedUser = User::find($user->id);
        if ($updatedUser->message_count >= 100) {
            UserAchievement::create([
                'user_id' => $user->id,
                'achievement_id' => 'prolific_writer',
                'earned_at' => now(),
            ]);
        }
    }
}

Best Practices

1. Name Actions Descriptively

// Good
CreateMessageWithActivityAction
UpdatePostAndNotifyFollowersAction
DeleteUserAndCleanupDataAction

// Avoid
MessageAction
PostAction
UserAction

2. Keep Actions Focused

Each action should represent a single business operation, even if it touches multiple tables.

3. Use Transactions for Consistency

Wrap related operations in database transactions to ensure data consistency.

4. Validate Everything

Use comprehensive validation rules for each operation to prevent invalid data.

5. Handle Authorization

Always check permissions before performing operations.

Next Steps


Chorus Actions provide a powerful, flexible system for handling complex write operations while maintaining real-time synchronization and offline support. The combination of multi-table operations, automatic validation, and type-safe client integration makes building sophisticated real-time applications straightforward and maintainable.