A Laravel package that provides an elegant way to generate and use Actions and Data Transfer Objects (DTOs) in your Laravel projects. This package promotes clean architecture by separating business logic into reusable Action classes and ensuring type-safe data handling with DTOs.
- Features
- Requirements
- Installation
- Quick Start
- Usage
- Real-world Examples
- API Reference
- Testing
- Contributing
- License
- 🚀 Simple Command Generation: Generate Actions and DTOs with simple Artisan commands
- 🔒 Type Safety: Built with PHP 8.2+ readonly classes for immutable data structures
- 🏗️ Clean Architecture: Promotes separation of concerns and clean code practices
- 🔄 Automatic Data Mapping: Seamless conversion between arrays, Form Requests, and Models
- ✅ Attribute-Based Validation: Use PHP attributes for declarative validation rules
- 🔧 Custom Validation: Support for custom validation callbacks and pipelines
- 🌳 Nested DTOs: Automatic resolution of nested DTOs and arrays of DTOs
- 🔄 Data Transformations: Built-in data transformation pipeline for clean data processing
- 📁 Customizable Paths: Configure custom paths for Actions and DTOs
- 🧪 Well Tested: Comprehensive test suite ensuring reliability
- 📖 Rich Documentation: Extensive documentation and examples
- PHP 8.2 or higher
- Laravel 11.0 or higher
You can install the package via Composer:
composer require holiq/action-dataThe package will automatically register its service provider.
Optionally, you can publish the configuration file:
php artisan vendor:publish --provider="Holiq\ActionData\ActionDataServiceProvider" --tag="config"After publishing, you can customize the paths in config/action-data.php:
return [
'action_path' => 'app/Actions',
'data_path' => 'app/DataTransferObjects',
];-
Generate an Action with DTO:
php artisan make:action CreateUserAction --with-dto=CreateUserData
-
Define your DTO with validation:
readonly class CreateUserData extends DataTransferObject { public function __construct( #[Required, Length(min: 2, max: 50)] public string $name, #[Required, Email] public string $email, ) {} }
-
Implement your Action:
readonly class CreateUserAction extends Action { public function execute(CreateUserData $data): User { return User::create($data->toArray()); } }
-
Use in your controller:
$userData = CreateUserData::resolve($request->validated()) ->validateAttributes(); $user = CreateUserAction::resolve()->execute($userData);
After publishing the configuration file, you can customize the paths where Actions and DTOs are generated:
// config/action-data.php
return [
'action_path' => 'app/Actions',
'data_path' => 'app/DataTransferObjects',
];Generate Actions using the Artisan command with various options:
# Basic action
php artisan make:action StoreUserAction
# Action in subdirectory
php artisan make:action User/StoreUserAction
# Action with auto-generated DTO
php artisan make:action StoreUserAction --with-dto=StoreUserData
# Force overwrite existing files
php artisan make:action StoreUserAction --forceBasic Action structure:
<?php
namespace App\Actions;
use Holiq\ActionData\Foundation\Action;
readonly class StoreUserAction extends Action
{
public function execute(): mixed
{
// Your business logic here
}
}Action with DTO parameter:
<?php
namespace App\Actions;
use App\DataTransferObjects\StoreUserData;
use Holiq\ActionData\Foundation\Action;
readonly class StoreUserAction extends Action
{
public function execute(StoreUserData $data): User
{
// Type-safe business logic with validated DTO
return User::create($data->toArray());
}
}Generate DTOs using the Artisan command:
# Basic DTO
php artisan make:dto CreateUserData
# DTO in subdirectory
php artisan make:dto User/CreateUserData
# Force overwrite existing files
php artisan make:dto CreateUserData --forceGenerated DTO structure:
<?php
namespace App\DataTransferObjects;
use Holiq\ActionData\Foundation\DataTransferObject;
readonly class CreateUserData extends DataTransferObject
{
final public function __construct(
// Define your properties with validation attributes
// #[Required, Length(min: 1, max: 255)] public string $name,
// #[Required, Email] public string $email,
) {}
}DTOs can be created from various data sources:
// From array
$userData = CreateUserData::resolve([
'first_name' => 'John',
'last_name' => 'Doe',
'email' => '[email protected]'
]);
// From Form Request
$userData = CreateUserData::resolveFrom($request);
// From Eloquent Model
$userData = CreateUserData::resolveFrom($user);Convert DTOs to arrays with different formatting options:
readonly class CreateUserData extends DataTransferObject
{
public function __construct(
public string $firstName,
public string $lastName,
public string $email,
) {}
}
$data = new CreateUserData('John', 'Doe', '[email protected]');
// Convert to snake_case (default)
$array = $data->toArray();
// Result: ['first_name' => 'John', 'last_name' => 'Doe', 'email' => '[email protected]']
// Convert to camelCase
$camelCase = $data->toCamelCase();
// Result: ['firstName' => 'John', 'lastName' => 'Doe', 'email' => '[email protected]']
// Convert to JSON
$json = $data->toJson();
// Convert with context-specific exclusions
$createArray = $data->toArrayForCreate();
$updateArray = $data->toArrayForUpdate();Control which properties are included in specific contexts:
readonly class CreateUserData extends DataTransferObject
{
public function __construct(
public string $firstName,
public string $lastName,
public string $email,
public ?string $password = null,
) {}
protected function toExcludedPropertiesOnCreate(): array
{
return []; // Include all properties for create
}
protected function toExcludedPropertiesOnUpdate(): array
{
return ['password']; // Exclude password from updates
}
}Laravel Action Data provides powerful validation through PHP attributes and custom callbacks.
Use PHP attributes for declarative validation rules:
use Holiq\ActionData\Attributes\Validation\{Required, Email, Length, Range, Pattern};
use Holiq\ActionData\Foundation\DataTransferObject;
readonly class CreateUserData extends DataTransferObject
{
public function __construct(
#[Required, Length(min: 2, max: 50)]
public string $name,
#[Required, Email]
public string $email,
#[Required, Range(min: 18, max: 120)]
public int $age,
#[Pattern(regex: '/^\+?[1-9]\d{1,14}$/')]
public ?string $phone = null,
) {}
}
// Validate using attributes
try {
$user = CreateUserData::resolve($data);
$user->validateAttributes();
// DTO is valid
} catch (\InvalidArgumentException $e) {
// Handle validation errors
echo $e->getMessage();
}Available validation attributes:
#[Required]- Field cannot be null, empty string, or empty array#[Email]- Validates email format#[Length(min: int, max: int)]- Validates string length#[Range(min: int|float, max: int|float)]- Validates numeric ranges#[Pattern(regex: string)]- Validates against regular expression
Use custom validation logic with chainable callbacks:
$user = new CreateUserData('John Doe', '[email protected]', 25);
// Single validation
$user->validate(
fn (CreateUserData $data) => str_contains($data->email, '@'),
'Email must contain @ symbol'
);
// Chain multiple validations
$user
->validate(fn ($data) => !empty($data->name), 'Name is required')
->validate(fn ($data) => $data->age >= 18, 'Must be adult')
->validateAttributes(); // Combine with attribute validationLaravel Action Data automatically resolves nested DTOs and arrays of DTOs:
readonly class AddressData extends DataTransferObject
{
public function __construct(
public string $street,
public string $city,
public string $country,
) {}
}
readonly class UserData extends DataTransferObject
{
public function __construct(
public string $name,
public string $email,
public AddressData $address, // Nested DTO
) {}
}
// Automatically resolves nested structure
$user = UserData::resolve([
'name' => 'John Doe',
'email' => '[email protected]',
'address' => [
'street' => '123 Main St',
'city' => 'Anytown',
'country' => 'USA',
],
]);
// Access nested data
echo $user->address->street; // "123 Main St"readonly class UserData extends DataTransferObject
{
public function __construct(
public string $name,
public AddressData $currentAddress,
/** @var AddressData[] */
public array $previousAddresses = [], // Array of DTOs
) {}
}
$user = UserData::resolve([
'name' => 'Jane Smith',
'currentAddress' => [
'street' => '456 Oak Ave',
'city' => 'Springfield',
'country' => 'USA',
],
'previousAddresses' => [
[
'street' => '789 Pine St',
'city' => 'Oldtown',
'country' => 'USA',
],
[
'street' => '321 Elm Dr',
'city' => 'Hometown',
'country' => 'USA',
],
],
]);
// Access array of DTOs
foreach ($user->previousAddresses as $address) {
echo $address->street; // Each item is an AddressData instance
}Apply automatic data transformations during DTO resolution to clean and format your data:
readonly class UserProfileData extends DataTransferObject
{
public function __construct(
public string $name,
public string $email,
public ?string $bio = null,
public int $age = 0,
) {}
/**
* Define transformations applied during resolve()
*/
protected static function transforms(): array
{
return [
'name' => fn ($value) => trim(strtoupper($value)),
'email' => fn ($value) => trim(strtolower($value)),
'bio' => fn ($value) => $value ? trim($value) : null,
'age' => fn ($value) => max(0, (int) $value), // Ensure non-negative
];
}
}
$profile = UserProfileData::resolve([
'name' => ' john doe ', // Becomes "JOHN DOE"
'email' => ' [email protected] ', // Becomes "[email protected]"
'bio' => ' Software developer ', // Becomes "Software developer"
'age' => '-5', // Becomes 0
]);Complex transformations example:
readonly class ProductData extends DataTransferObject
{
public function __construct(
public string $name,
public float $price,
/** @var string[] */
public array $tags,
) {}
protected static function transforms(): array
{
return [
'name' => fn ($value) => ucwords(trim($value)),
'price' => fn ($value) => round((float) $value, 2),
'tags' => fn ($value) => is_array($value)
? array_values(array_map('strtolower', array_filter($value)))
: [],
];
}
}
$product = ProductData::resolve([
'name' => ' awesome widget ', // Becomes "Awesome Widget"
'price' => '19.999', // Becomes 20.0
'tags' => ['Electronics', '', 'GADGET', null, 'Popular'], // Becomes ["electronics", "gadget", "popular"]
]);// Data Transfer Object with Validation
namespace App\DataTransferObjects;
use Holiq\ActionData\Attributes\Validation\Email;
use Holiq\ActionData\Attributes\Validation\Length;
use Holiq\ActionData\Attributes\Validation\Pattern;
use Holiq\ActionData\Attributes\Validation\Required;
use Holiq\ActionData\Foundation\DataTransferObject;
readonly class CreateUserData extends DataTransferObject
{
public function __construct(
#[Required]
#[Length(min: 2, max: 50)]
public string $firstName,
#[Required]
#[Length(min: 2, max: 50)]
public string $lastName,
#[Required]
#[Email]
public string $email,
#[Required]
#[Length(min: 8)]
public string $password,
#[Pattern(regex: '/^\+?[1-9]\d{1,14}$/')]
public ?string $phone = null,
) {}
/**
* Apply data transformations
*/
protected static function transforms(): array
{
return [
'firstName' => fn ($value) => ucfirst(trim($value)),
'lastName' => fn ($value) => ucfirst(trim($value)),
'email' => fn ($value) => strtolower(trim($value)),
'phone' => fn ($value) => $value ? preg_replace('/\D/', '', $value) : null,
];
}
protected function toExcludedPropertiesOnUpdate(): array
{
return ['password']; // Don't include password in updates
}
}
// Action Class
namespace App\Actions\User;
use App\DataTransferObjects\CreateUserData;
use App\Models\User;
use Holiq\ActionData\Foundation\Action;
use Illuminate\Support\Facades\Hash;
readonly class CreateUserAction extends Action
{
public function execute(CreateUserData $data): User
{
// Data is already validated and transformed
return User::create([
'first_name' => $data->firstName,
'last_name' => $data->lastName,
'email' => $data->email,
'password' => Hash::make($data->password),
'phone' => $data->phone,
]);
}
}
// Form Request
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateUserRequest extends FormRequest
{
public function rules(): array
{
return [
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'min:8', 'confirmed'],
'phone' => ['nullable', 'string', 'max:20'],
];
}
}
// Controller
namespace App\Http\Controllers;
use App\Actions\User\CreateUserAction;
use App\DataTransferObjects\CreateUserData;
use App\Http\Requests\CreateUserRequest;
use Illuminate\Http\JsonResponse;
class UserController extends Controller
{
public function store(CreateUserRequest $request): JsonResponse
{
try {
// Resolve and validate DTO
$userData = CreateUserData::resolve($request->validated())
->validateAttributes();
// Execute action with validated DTO
$user = CreateUserAction::resolve()->execute($userData);
return response()->json([
'message' => 'User created successfully',
'data' => $user
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->getMessage()
], 422);
}
}
}// Address DTO
readonly class AddressData extends DataTransferObject
{
public function __construct(
#[Required] public string $street,
#[Required] public string $city,
#[Required] public string $state,
#[Required] public string $zipCode,
#[Required] public string $country,
) {}
}
// Order Item DTO
readonly class OrderItemData extends DataTransferObject
{
public function __construct(
#[Required] public string $productId,
#[Required] public int $quantity,
#[Required] public float $price,
) {}
protected static function transforms(): array
{
return [
'quantity' => fn ($value) => max(1, (int) $value),
'price' => fn ($value) => round((float) $value, 2),
];
}
}
// Main Order DTO
readonly class CreateOrderData extends DataTransferObject
{
public function __construct(
#[Required] public string $customerId,
#[Required] public AddressData $shippingAddress,
#[Required] public AddressData $billingAddress,
/** @var OrderItemData[] */
#[Required] public array $items,
public ?string $notes = null,
) {}
}
// Usage
$orderData = CreateOrderData::resolve([
'customer_id' => '12345',
'shipping_address' => [
'street' => '123 Main St',
'city' => 'Anytown',
'state' => 'CA',
'zip_code' => '12345',
'country' => 'USA',
],
'billing_address' => [
'street' => '456 Oak Ave',
'city' => 'Somewhere',
'state' => 'NY',
'zip_code' => '67890',
'country' => 'USA',
],
'items' => [
[
'product_id' => 'prod-1',
'quantity' => 2,
'price' => 29.99,
],
[
'product_id' => 'prod-2',
'quantity' => 1,
'price' => 15.50,
],
],
'notes' => 'Please handle with care',
]);// Action with Dependencies
namespace App\Actions\User;
use App\DataTransferObjects\CreateUserData;
use App\Models\User;
use App\Services\EmailService;
use App\Services\UserService;
use Holiq\ActionData\Foundation\Action;
readonly class CreateUserWithNotificationAction extends Action
{
public function __construct(
private UserService $userService,
private EmailService $emailService,
) {}
public function execute(CreateUserData $data): User
{
$user = $this->userService->create($data);
$this->emailService->sendWelcomeEmail($user);
return $user;
}
}
// Usage in Controller
$user = CreateUserWithNotificationAction::resolve()->execute($userData);Resolves an Action instance from Laravel's container with optional parameters.
resolve(array $data): static
Creates a DTO instance from an array with automatic key transformation and data transformations.
resolveFrom(FormRequest|Model|array $abstract): static
Creates a DTO instance from various data sources.
toArray(): array - Converts to snake_case array
toCamelCase(): array - Converts to camelCase array
toArrayForCreate(): array - Excludes properties from toExcludedPropertiesOnCreate()
toArrayForUpdate(): array - Excludes properties from toExcludedPropertiesOnUpdate()
toJson(int $options = 0): string - Converts to JSON string
validate(callable $validator, string $message): static
Validates using a custom callback.
validateAttributes(): static
Validates using PHP attributes.
has(string $property): bool - Checks if property exists
get(string $property, mixed $default = null): mixed - Gets property value
clone(): static - Creates a clone
tap(callable $callback): static - Executes callback and returns instance
dump(): static - Dumps data for debugging
dd(): never - Dumps data and dies
toExcludedPropertiesOnCreate(): array - Properties to exclude in create context
toExcludedPropertiesOnUpdate(): array - Properties to exclude in update context
transforms(): array - Data transformations for resolve()
Generates a new Action class.
Options:
--with-dto=DtoName- Auto-generate corresponding DTO--force- Overwrite existing files
Generates a new Data Transfer Object class.
Options:
--force- Overwrite existing files
Run the test suite:
composer testRun static analysis:
composer analyseRun code formatting:
composer formatContributions are welcome! Please feel free to submit a Pull Request.
The MIT License (MIT). Please see License File for more information.