Best Practices
This guide covers proven patterns and best practices specifically for creating and organizing Actions and DTOs in Actionable. These recommendations come from real-world usage and help you build maintainable, scalable applications.
Action Best Practices
Single Responsibility Principle
Each action should have one clear, focused responsibility:
// ✅ Good - focused responsibility
class SendOrderConfirmationEmail
{
use IsRunnable, IsDispatchable;
public function handle(Order $order): void
{
Mail::to($order->customer_email)
->send(new OrderConfirmationEmail($order));
}
}
// ❌ Avoid - too many responsibilities
class ProcessOrder
{
use IsRunnable;
public function handle(OrderData $data): Order
{
// Creates order
$order = Order::create($data->toArray());
// Processes payment
$payment = Stripe::charge($data->paymentToken, $data->total);
// Updates inventory
foreach ($data->items as $item) {
Product::find($item->productId)->decrement('stock', $item->quantity);
}
// Sends emails
Mail::to($data->customerEmail)->send(new OrderConfirmation($order));
Mail::to(config('admin.email'))->send(new NewOrderAlert($order));
// Updates analytics
Analytics::track('order_created', $order->id);
return $order;
}
}
Instead, break it down into focused actions:
// ✅ Better - focused actions
class CreateOrder
{
use IsRunnable;
public function handle(OrderData $data): Order
{
return Order::create($data->toArray());
}
}
class ProcessOrderWorkflow
{
use IsRunnable;
public function handle(OrderData $data): Order
{
$order = CreateOrder::run($data);
ProcessPayment::dispatch($order, $data->paymentToken);
UpdateInventory::dispatch($order);
SendOrderConfirmation::dispatch($order);
TrackOrderCreated::dispatch($order);
return $order;
}
}
Action Naming Conventions
Use descriptive, verb-based names that clearly indicate what the action does:
// ✅ Good - clear, descriptive names
SendWelcomeEmail::run($user);
CalculateShippingCost::run($order);
ProcessCreditCardPayment::run($payment);
GenerateMonthlyReport::run($month, $year);
ValidateOrderData::run($orderData);
// ❌ Avoid - vague or noun-based names
EmailUser::run($user); // What kind of email?
Calculate::run($order); // Calculate what?
Payment::run($payment); // Process? Validate? Refund?
Report::run($month, $year); // What kind of report?
Validator::run($orderData); // Validate what?
Parameter Organization
Structure action parameters thoughtfully:
// ✅ Good - logical parameter order
class CreateUser
{
use IsRunnable;
public function handle(
UserRegistrationData $userData, // Primary data first
bool $sendWelcomeEmail = true, // Options second
?string $referralCode = null // Optional context last
): User {
// Implementation
}
}
// ✅ Good - use DTOs for complex data
class ProcessOrder
{
use IsRunnable;
public function handle(OrderData $orderData): Order
{
// All order-related data is organized in the DTO
}
}
// ❌ Avoid - too many primitive parameters
class CreateUser
{
use IsRunnable;
public function handle(
string $name,
string $email,
string $password,
?string $phone,
bool $acceptsMarketing,
?string $referralCode,
string $timezone,
array $preferences
): User {
// Hard to remember parameter order and types
}
}
Error Handling Patterns
Implement consistent error handling in your actions:
class ProcessPayment
{
use IsRunnable, IsDispatchable;
public function handle(Order $order, string $paymentToken): Payment
{
try {
$payment = $this->chargePayment($order, $paymentToken);
// Update order status on success
$order->update(['status' => 'paid']);
return $payment;
} catch (PaymentDeclinedException $e) {
// Handle specific business exceptions
Log::warning('Payment declined', [
'order_id' => $order->id,
'reason' => $e->getMessage()
]);
$order->update(['status' => 'payment_failed']);
throw new OrderPaymentFailedException(
"Payment declined for order {$order->id}: {$e->getMessage()}",
previous: $e
);
} catch (PaymentServiceException $e) {
// Handle service-level exceptions
Log::error('Payment service error', [
'order_id' => $order->id,
'error' => $e->getMessage()
]);
// Don't update order status - this might be temporary
throw new TemporaryPaymentException(
"Payment service temporarily unavailable",
previous: $e
);
}
}
}
Action Composition Patterns
Build complex workflows by composing simple actions:
// ✅ Good - composition pattern
class UserRegistrationWorkflow
{
use IsRunnable;
public function handle(UserRegistrationData $userData): User
{
// Each step is a focused action
$user = CreateUser::run($userData);
// Queue background tasks
SendWelcomeEmail::dispatch($user);
if ($userData->acceptsMarketing) {
SubscribeToNewsletter::dispatch($user);
}
if ($userData->referralCode) {
ProcessReferral::dispatch($user, $userData->referralCode);
}
// Track the event
TrackUserRegistration::dispatch($user, $userData->source);
return $user;
}
}
DTO Best Practices
Descriptive Naming
Name DTOs based on their specific purpose and context:
// ✅ Good - purpose-specific names
UserRegistrationData::class // For user registration forms
OrderCheckoutData::class // For order checkout process
ProductApiResponse::class // For API product responses
PaymentRequestData::class // For payment processing
EmailTemplateData::class // For email template rendering
// ❌ Avoid - generic names
UserData::class // Too generic - what kind of user data?
OrderData::class // Registration? Update? Checkout?
ProductData::class // API response? Database insert?
PaymentData::class // Request? Response? Configuration?
EmailData::class // Template? Log? Configuration?
Property Organization
Organize DTO properties logically:
// ✅ Good - logical grouping and ordering
class UserRegistrationData
{
use ArrayConvertible;
public function __construct(
// Core identity first
public string $name,
public string $email,
public string $password,
// Contact information
public ?string $phone = null,
// Preferences and settings
public bool $acceptsMarketing = false,
public string $timezone = 'UTC',
// Optional metadata last
public ?string $referralCode = null,
public ?array $customFields = null
) {}
}
Attribute Usage Patterns
Use attributes consistently and purposefully:
class OrderApiResponse
{
use ArrayConvertible;
public function __construct(
// API field naming consistency
#[FieldName('order_id')]
public int $orderId,
#[FieldName('customer_email')]
public string $customerEmail,
// Date formatting for APIs
#[FieldName('created_at')]
#[DateFormat('c')] // ISO 8601
public DateTime $createdAt,
#[FieldName('delivery_date')]
#[DateFormat('Y-m-d')] // Date only
public ?DateTime $deliveryDate,
// Nested data structures
#[FieldName('line_items')]
#[ArrayOf(OrderItemData::class)]
public array $lineItems,
// Internal data not exposed in API
#[Ignore]
public string $internalReference,
#[Ignore]
public bool $requiresReview
) {}
}
Immutability Patterns
Leverage readonly classes for immutable data:
// ✅ Good - immutable DTO with helper methods
readonly class ProductData
{
use ArrayConvertible;
public function __construct(
public string $name,
public float $price,
public int $stock,
public bool $isActive = true
) {}
public function withPrice(float $newPrice): self
{
return new self(
name: $this->name,
price: $newPrice,
stock: $this->stock,
isActive: $this->isActive
);
}
public function withStock(int $newStock): self
{
return new self(
name: $this->name,
price: $this->price,
stock: $newStock,
isActive: $this->isActive
);
}
}
// Usage
$product = ProductData::fromArray($request->validated());
$discountedProduct = $product->withPrice($product->price * 0.9);
Validation Integration
Integrate DTOs with Laravel's validation:
class UserRegistrationRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
'phone' => 'nullable|string|max:20',
'accepts_marketing' => 'boolean',
];
}
public function toDto(): UserRegistrationData
{
return UserRegistrationData::fromArray($this->validated());
}
}
// In controller
class AuthController extends Controller
{
public function register(UserRegistrationRequest $request)
{
$userData = $request->toDto();
$user = RegisterUser::run($userData);
return response()->json(['user' => $user]);
}
}
Organizational Patterns
Directory Structure
Organize actions and DTOs by domain:
app/
├── Actions/
│ ├── Auth/
│ │ ├── LoginUser.php
│ │ ├── RegisterUser.php
│ │ └── LogoutUser.php
│ ├── Orders/
│ │ ├── CreateOrder.php
│ │ ├── ProcessPayment.php
│ │ ├── UpdateInventory.php
│ │ └── Shipping/
│ │ ├── CalculateShipping.php
│ │ └── CreateShipment.php
│ ├── Email/
│ │ ├── SendWelcomeEmail.php
│ │ ├── SendOrderConfirmation.php
│ │ └── SendPasswordReset.php
│ └── Reports/
│ ├── GenerateSalesReport.php
│ └── GenerateUserReport.php
└── Dtos/
├── Auth/
│ ├── LoginData.php
│ ├── RegistrationData.php
│ └── PasswordResetData.php
├── Orders/
│ ├── OrderData.php
│ ├── OrderItemData.php
│ └── PaymentData.php
├── Api/
│ ├── UserResponse.php
│ ├── OrderResponse.php
│ └── ProductResponse.php
└── Forms/
├── ContactFormData.php
└── ProfileUpdateData.php
Action and DTO Pairing
Create consistent patterns for actions and their associated DTOs:
// Consistent naming and organization
namespace App\Actions\Users;
class CreateUser { /* ... */ }
namespace App\Dtos\Users;
class UserCreationData { /* ... */ }
// Usage pattern
$userData = UserCreationData::fromArray($request->validated());
$user = CreateUser::run($userData);
Queue Organization
Organize queueable actions by processing requirements:
// Fast operations - default queue
SendEmailNotification::dispatch($user, $message);
// Heavy processing - dedicated queue
ProcessVideoUpload::dispatchOn('media-processing', $video);
GenerateLargeReport::dispatchOn('reports', $reportData);
// Critical operations - high priority queue
ProcessPayment::dispatchOn('high-priority', $order, $payment);
SendUrgentAlert::dispatchOn('urgent', $alertData);
What's Next?
- Testing Guide - Master testing your actions and DTOs