⚠️ This package is currently under active development. The API may change in future versions. Use with caution in production environments.
Arch App Services - Clean architectural abstractions for building scalable Laravel applications
- Action Pattern: Clean, single-purpose classes for business logic
- Action Logging: Robust action execution logging with fallback mechanism
- Repository Pattern: Database query abstraction with pagination support
- Repository Caching: Automatic caching layer for repository methods with configurable TTL
- Model Manager: CRUD operations with batch processing and duplicate handling capabilities
- Data Builder: Pipeline-based data transformation using Laravel Pipeline
- Data Validation: Integrated validation using Laravel's validation system
- Data Transfer Objects (DTO): Type-safe data objects with attribute-based validation
- Service Layer: Combines Repository and Model Manager for complete CRUD operations
- PHPStan Rules: Custom architecture rules enforcing best practices
- Type Safety: Full PHPDoc type annotations and generics support
- Laravel 12+ Ready: Built for modern Laravel features and conventions
- 100% Test Coverage: Comprehensive test suite ensuring reliability
You can install the package via composer:
composer require pekral/arch-app-servicesThe package will automatically register its service provider.
Optionally, you can publish the configuration file:
php artisan vendor:publish --tag="arch-config"You can also publish the stub files for customization:
php artisan vendor:publish --tag="arch-stubs"The package provides Artisan commands for generating boilerplate code:
| Command | Description |
|---|---|
make:arch-action |
Create a new Action class |
make:arch-service |
Create a complete service stack (Service, Repository, ModelManager) |
make:arch-dto |
Create a new DTO class |
make:arch-validation-rules |
Create a new ValidationRules class |
# Create an Action class
php artisan make:arch-action User/CreateUserAction
# Create a complete service stack for User model
php artisan make:arch-service "App\Models\User"
# Create a DTO class
php artisan make:arch-dto User/CreateUserDTO
# Create a ValidationRules class
php artisan make:arch-validation-rules User/UserValidationRulesFor detailed documentation about code generation, see Code Generation Documentation.
This package provides a clean architecture with the following components:
- Actions: Single-purpose classes that handle specific business operations
- Action Logging: Robust logging system with configurable channels and fallback
- Services: Combine Repository and Model Manager for complete CRUD operations
- Repositories: Handle read operations with advanced querying capabilities and caching
- Model Managers: Handle write operations (create, update, delete)
- Data Builder: Transform data using pipeline pattern
- Data Validator: Integrated validation using Laravel's validation system
- Data Transfer Objects (DTO): Type-safe data objects with attribute-based validation
- Pipes: Reusable data transformation components
<?php
namespace App\Repositories;
use Pekral\Arch\Repository\CacheableRepository;
use Pekral\Arch\Repository\Mysql\BaseRepository;
use App\Models\User;
/**
* @extends \Pekral\Arch\Repository\Mysql\BaseRepository<\App\Models\User>
*/
final readonly class UserRepository extends BaseRepository
{
use CacheableRepository;
protected function getModelClassName(): string
{
return User::class;
}
}The repository provides a fluent interface for building complex queries:
<?php
namespace App\Repositories;
use Pekral\Arch\Repository\Mysql\BaseRepository;
use App\Models\User;
final readonly class UserRepository extends BaseRepository
{
protected function getModelClassName(): string
{
return User::class;
}
public function findActiveUsers(): Collection
{
return $this->query()
->where('active', true)
->orderBy('name')
->get();
}
public function findUsersWithPosts(): Collection
{
return $this->query()
->whereHas('posts')
->with('posts')
->get();
}
public function searchUsers(string $term): Collection
{
return $this->query()
->where(function ($query) use ($term) {
$query->where('name', 'like', "%{$term}%")
->orWhere('email', 'like', "%{$term}%");
})
->get();
}
}
// Usage in Actions or Services
$users = $userRepository->query()
->where('active', true)
->whereIn('role', ['admin', 'moderator'])
->with(['posts', 'profile'])
->orderBy('created_at', 'desc')
->limit(10)
->get();The repository provides automatic caching through the CacheableRepository trait:
<?php
namespace App\Actions\User;
use App\Repositories\UserRepository;
use App\Models\User;
final readonly class GetUserCached
{
public function __construct(private UserRepository $userRepository)
{
}
/**
* @param array<string, mixed> $filters
*/
public function __invoke(array $filters): User
{
// Automatically cached for configured TTL
return $this->userRepository->cache()->getOneByParams($filters);
}
}
// Clear specific cache entry
$this->userRepository->cache()->clearCache('getOneByParams', $filters);
// Clear all cache entries (use with caution)
$this->userRepository->cache()->clearAllCache();<?php
namespace App\Services;
use Pekral\Arch\ModelManager\Mysql\BaseModelManager;
use App\Models\User;
/**
* @extends \Pekral\Arch\ModelManager\Mysql\BaseModelManager<\App\Models\User>
*/
final class UserModelManager extends BaseModelManager
{
protected function getModelClassName(): string
{
return User::class;
}
}<?php
namespace App\Services;
use Pekral\Arch\ModelManager\Mysql\BaseModelManager;
use Pekral\Arch\Repository\Mysql\BaseRepository;
use Pekral\Arch\Service\BaseModelService;
use App\Models\User;
/**
* @extends \Pekral\Arch\Service\BaseModelService<\App\Models\User>
*/
final readonly class UserModelService extends BaseModelService
{
public function __construct(
private UserModelManager $userModelManager,
private UserRepository $userRepository
) {
}
protected function getModelClass(): string
{
return User::class;
}
protected function getModelManager(): BaseModelManager
{
return $this->userModelManager;
}
protected function getRepository(): BaseRepository
{
return $this->userRepository;
}
}The Model Manager provides powerful bulk operations including duplicate handling:
<?php
namespace App\Actions\User;
use Pekral\Arch\Examples\Services\User\UserModelManager;
use Pekral\Arch\Tests\Models\User;
final readonly class BulkImportUsers
{
public function __construct(
private UserModelManager $userModelManager,
) {
}
/**
* @param array<int, array<string, mixed>> $userData
* @return array{
* total_processed: int,
* created: int,
* ignored: int
* }
*/
public function __invoke(array $userData): array
{
if ($userData === []) {
return [
'total_processed' => 0,
'created' => 0,
'ignored' => 0,
];
}
// Prepare data with timestamps
$preparedData = $this->prepareUserData($userData);
// Count existing users before import
$existingCount = User::count();
// Use insertOrIgnore to handle duplicates
$processedCount = $this->userModelManager->insertOrIgnore($preparedData);
// Count users after import
$newCount = User::count();
$createdCount = $newCount - $existingCount;
$ignoredCount = $processedCount - $createdCount;
return [
'total_processed' => $processedCount,
'created' => $createdCount,
'ignored' => $ignoredCount,
];
}
/**
* @param array<int, array<string, mixed>> $userData
* @return array<int, array<string, mixed>>
*/
private function prepareUserData(array $userData): array
{
$now = now();
return array_map(function (array $data) use ($now): array {
return array_merge($data, [
'created_at' => $now,
'updated_at' => $now,
]);
}, $userData);
}
}Actions are single-purpose classes that handle specific business operations. They can use DataBuilder for transformation and DataValidator for validation:
<?php
namespace App\Actions\User;
use Pekral\Arch\DataBuilder\DataBuilder;
use Pekral\Arch\DataValidation\DataValidator;
use App\Actions\User\Pipes\LowercaseEmailPipe;
use App\Actions\User\Pipes\UcFirstNamePipe;
use App\Services\UserModelService;
use App\Models\User;
final readonly class CreateUser
{
use DataBuilder;
use DataValidator;
public function __construct(
private UserModelService $userModelService,
private VerifyUserAction $verifyUserAction,
) {
}
/**
* @param array<string, mixed> $data
* @throws \Illuminate\Validation\ValidationException
*/
public function __invoke(array $data): User
{
// Validate input data
$this->validate($data, [
'email' => 'required|email',
'name' => 'required|string',
], []);
// Transform data using pipeline
$normalizedData = $this->build($data, [
'email' => LowercaseEmailPipe::class,
'name' => UcFirstNamePipe::class,
]);
// Create user
$user = $this->userModelService->create($normalizedData);
// Send verification email
($this->verifyUserAction)($user);
return $user;
}
}DTOs provide type-safe data objects with attribute-based validation using Spatie Laravel Data:
<?php
namespace App\DTO;
use Pekral\Arch\DTO\DataTransferObject;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\Nullable;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\Rule;
final class CreateUserDTO extends DataTransferObject
{
public function __construct(
#[Email, Required]
public string $email,
#[Max(255), Required]
public string $name,
#[Nullable, Rule(new CzechPhoneRule())]
public ?string $phone = null,
) {
}
}
// Usage
$dto = CreateUserDTO::from($request->all());
$dto = CreateUserDTO::validateAndCreate($data); // With validation
$array = $dto->toArray();Create custom validation rules that can be used in DTOs:
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class CzechPhoneRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!preg_match('/^\+420\d{9}$/', $value)) {
$fail('The phone number must be in format +420XXXXXXXXX.');
}
}
}Create centralized validation rules that can be used in both Laravel Requests and DTOs:
<?php
namespace App\Rules;
final class UserValidationRules
{
/**
* @return array<string, array<int, mixed>>
*/
public static function rules(): array
{
return [
'email' => ['required', 'email'],
'name' => ['required', 'max:255'],
'phone' => ['nullable', new CzechPhoneRule()],
];
}
}
// In Laravel Request
final class CreateUserRequest extends FormRequest
{
public function rules(): array
{
return UserValidationRules::rules();
}
}
// In DTO
final class CreateUserDTO extends DataTransferObject
{
public function __construct(
#[Rule(UserValidationRules::emailRules())]
public string $email,
#[Rule(UserValidationRules::nameRules())]
public string $name,
) {
}
}<?php
namespace App\Actions\User\Pipes;
interface BuilderPipe
{
/**
* Transform user data.
*
* @param array<string, mixed> $data
* @param callable(array<string, mixed>): array<string, mixed> $next
* @return array<string, mixed>
*/
public function handle(array $data, callable $next): array;
}
final readonly class LowercaseEmailPipe implements BuilderPipe
{
public function handle(array $data, callable $next): array
{
if (isset($data['email']) && is_string($data['email'])) {
$data['email'] = strtolower($data['email']);
}
return $next($data);
}
}
final readonly class UcFirstNamePipe implements BuilderPipe
{
public function handle(array $data, callable $next): array
{
if (isset($data['name']) && is_string($data['name'])) {
$data['name'] = str($data['name'])->lower()->ucfirst()->value();
}
return $next($data);
}
}<?php
namespace App\Http\Controllers;
use App\Actions\User\CreateUser;
use App\Actions\User\GetUser;
use App\Actions\User\UpdateUserName;
use Illuminate\Http\Request;
final class UserController extends Controller
{
public function store(Request $request, CreateUser $createUser)
{
$user = ($createUser)($request->validated());
return response()->json($user, 201);
}
public function show(Request $request, GetUser $getUser)
{
$user = ($getUser)($request->only(['email', 'name']));
return response()->json($user);
}
public function updateName(Request $request, UpdateUserName $updateUserName)
{
$user = User::findOrFail($request->user_id);
($updateUserName)($request->name, $user);
return response()->json(['message' => 'Name updated successfully']);
}
}paginateByParams(array $params, array $with = [], ?int $itemsPerPage = null, array $orderBy = [], array $groupBy = [])- Paginate resultsgetOneByParams(Collection|array $params, array $with = [], array $orderBy = [])- Get one record or throw exceptionfindOneByParams(Collection|array $params, array $with = [], array $orderBy = [])- Find one record or return nullcountByParams(Collection|array $params, array $groupBy = [])- Count recordsquery()- Start a fluent query builder interfacecreateQueryBuilder()- Create a new query builder instancecache()- Get cache wrapper for automatic caching
cache()->paginateByParams(...)- Cached paginationcache()->getOneByParams(...)- Cached single record retrievalcache()->findOneByParams(...)- Cached single record findcache()->countByParams(...)- Cached countcache()->clearCache(string $method, array $args)- Clear specific cache entrycache()->clearAllCache()- Clear all cache entries
create(array $data)- Create single recordupdate(Model $model, array $data)- Update existing modelupdateOrCreate(array $attributes, array $values = [])- Update existing record or create a new one if it doesn't existgetOrCreate(array $attributes, array $values = [])- Get existing record or create a new one if it doesn't existbulkCreate(array $dataArray)- Bulk create recordsinsertOrIgnore(array $dataArray)- Bulk insert records, ignoring duplicates based on unique constraintsbulkUpdate(array $dataArray, string $keyColumn = 'id')- Bulk update recordsrawMassUpdate(array $values, array|string|null $uniqueBy = null)- Mass update records using CASE WHEN SQL statement (requires MassUpdatable trait)deleteByParams(array $parameters)- Delete by parametersbulkDeleteByParams(array $parameters)- Batch delete by parameters
CRUD Operations:
create(array $data)- Create with validationupdateModel(Model $model, array $data)- Update existing modeldeleteModel(Model $model)- Delete modeldeleteByParams(array $parameters)- Delete by parametersbulkDeleteByParams(array $parameters)- Batch delete by parameters
Read Operations:
findOneByParams(array $parameters, array $with = [], array $orderBy = [])- Find one or nullgetOneByParams(array $parameters, array $with = [], array $orderBy = [])- Find one or throw exceptionpaginateByParams(array $parameters = [], array $with = [], ?int $perPage = null, array $orderBy = [], array $groupBy = [])- PaginatecountByParams(array $parameters, array $groupBy = [])- Count records
Bulk Operations:
bulkCreate(array $data)- Bulk create recordsinsertOrIgnore(array $data)- Bulk insert records, ignoring duplicatesbulkUpdate(array $data, string $keyColumn = 'id')- Bulk update recordsbulkDeleteByParams(array $parameters)- Batch delete by parameters
The Data Builder uses Laravel's Pipeline to transform data through a series of pipes. It supports both general pipes (applied to all data) and specific pipes (applied to specific fields).
use Pekral\Arch\DataBuilder\DataBuilder;
final readonly class ProcessUserData
{
public function __construct(private DataBuilder $dataBuilder)
{
}
public function handle(array $userData): array
{
return $this->dataBuilder->build($userData, [
// General pipes (applied to all data)
ValidateEmailPipe::class,
SanitizeDataPipe::class,
// Specific pipes (applied to specific fields)
'email' => LowercaseEmailPipe::class,
'name' => UcFirstNamePipe::class,
]);
}
}- General Pipes: Applied first to all data
- Specific Pipes: Applied after general pipes to specific fields
final readonly class CreateUser
{
use DataBuilder;
public function __invoke(array $data): User
{
$dataNormalized = $this->build($data, [
'email' => LowercaseEmailPipe::class,
'name' => UcFirstNamePipe::class,
]);
return $this->userModelService->create($dataNormalized);
}
}The Data Validator provides a simple interface for validating data using Laravel's validation system.
use Pekral\Arch\DataValidation\DataValidator;
final readonly class ValidateUserData
{
use DataValidator;
public function validateUser(array $data): array
{
return $this->validate($data, [
'email' => 'required|email|unique:users',
'name' => 'required|string|max:255',
'password' => 'required|string|min:6',
], [
'email.required' => 'Email is required.',
'email.email' => 'Email must be a valid email address.',
'name.required' => 'Name is required.',
]);
}
}final readonly class CreateUser
{
use DataValidator;
public function __invoke(array $data): User
{
$this->validate($data, [
'email' => 'required|email|unique:users',
'name' => 'required|string|max:255',
], []);
return $this->userModelService->create($data);
}
}try {
$validatedData = $this->validate($data, $rules, $messages);
} catch (\Illuminate\Validation\ValidationException $e) {
$errors = $e->errors();
// Handle validation errors
}The Action Logger provides robust logging for action execution with automatic fallback mechanism. It logs action start, success, and failure events with configurable logging channels.
use Pekral\Arch\Action\ActionLogger;
final readonly class ProcessOrderAction
{
use ActionLogger;
public function __invoke(array $orderData): Order
{
$startTime = microtime(true);
$this->logActionStart('ProcessOrder', [
'order_id' => $orderData['id'] ?? null,
'customer_id' => $orderData['customer_id'] ?? null
]);
try {
$order = $this->orderService->process($orderData);
$this->logActionSuccess('ProcessOrder', [
'order_id' => $order->id,
'total_amount' => $order->total_amount,
'processing_time' => microtime(true) - $startTime
]);
return $order;
} catch (\Exception $e) {
$this->logActionFailure('ProcessOrder', $e->getMessage(), [
'order_data' => $orderData,
'error_type' => get_class($e),
'stack_trace' => $e->getTraceAsString()
]);
throw $e;
}
}
}logActionStart(string $action, array $context = [])- Log action startlogActionSuccess(string $action, array $context = [])- Log successful completionlogActionFailure(string $action, string $error, array $context = [])- Log failure
Configure action logging in your config/arch.php file:
return [
// ... other config
'action_logging' => [
'channel' => env('ARCH_ACTION_LOG_CHANNEL', 'stack'),
'enabled' => env('ARCH_ACTION_LOGGING_ENABLED', true),
],
];# Use specific logging channel for actions
ARCH_ACTION_LOG_CHANNEL=actions
# Disable action logging
ARCH_ACTION_LOGGING_ENABLED=falseCreate a dedicated logging channel in config/logging.php:
'channels' => [
'actions' => [
'driver' => 'daily',
'path' => storage_path('logs/actions.log'),
'level' => 'info',
'days' => 14,
],
],If the primary logger fails, ActionLogger automatically falls back to writing detailed error information to storage/logs/arch.log:
[2025-01-15 14:30:25] ARCH FALLBACK LOG
Level: INFO
Action: ProcessOrder
Type: start
Original Message: Action started: ProcessOrder
Context: {
"order_id": 12345,
"customer_id": 67890
}
Logging Error: Redis connection timeout
Logging Error File: /app/vendor/predis/Client.php:156
Stack Trace: #0 /app/vendor/predis/Client.php...
--------------------------------------------------------------------------------
This ensures that no action execution is interrupted by logging failures, while still providing complete debugging information.
The package publishes a configuration file with the following options:
return [
'default_items_per_page' => 15,
'exceptions' => [
'should_not_happen' => \RuntimeException::class,
],
'action_logging' => [
'channel' => env('ARCH_ACTION_LOG_CHANNEL', 'stack'),
'enabled' => env('ARCH_ACTION_LOGGING_ENABLED', true),
],
'repository_cache' => [
'enabled' => env('ARCH_REPOSITORY_CACHE_ENABLED', true),
'ttl' => env('ARCH_REPOSITORY_CACHE_TTL', 3600), // 1 hour default
'prefix' => env('ARCH_REPOSITORY_CACHE_PREFIX', 'arch_repo'),
],
];# Action logging configuration
ARCH_ACTION_LOG_CHANNEL=actions
ARCH_ACTION_LOGGING_ENABLED=true
# Repository caching configuration
ARCH_REPOSITORY_CACHE_ENABLED=true
ARCH_REPOSITORY_CACHE_TTL=3600
ARCH_REPOSITORY_CACHE_PREFIX=arch_repoThe package includes custom PHPStan rules that enforce architectural best practices:
- NoEloquentStorageMethodsInActionsRule - Prevents direct Eloquent storage method calls (
save(),create(),delete(), etc.) in Action classes - NoDirectDatabaseQueriesInActionsRule - Prevents direct database query calls (
where(),find(),get(), etc.) in Action classes - OnlyModelManagersCanPersistDataRule - Ensures data persistence operations are only performed in ModelManager or ModelService classes
- NoLaravelHelpersForActionsRule - Prevents using Laravel helper functions (
app(),resolve(),make()) to resolve Action classes. Actions must be injected via constructor - ServiceNamingConventionRule - Ensures that all Service classes extending
BaseModelServiceend withModelServicesuffix - ActionInvokeMethodRule - Ensures Action classes expose only one public entry point:
__invoke()(constructor excluded) - ValidationRulesMethodNamingRule - Ensures ValidationRules classes have only static methods ending with
Rulessuffix - ValidationRulesNoInstantiationRule - Prevents instantiation of ValidationRules classes via
new
These rules enforce clean architecture by ensuring:
- Actions use Repository pattern for data retrieval
- Actions use ModelManager pattern for data persistence
- Database operations are properly separated by concerns
// ❌ Violation: Direct query in Action
final readonly class GetUsers implements ArchAction
{
public function __invoke(): Collection
{
return User::where('active', true)->get(); // PHPStan error!
}
}
// ✅ Correct: Using Repository
final readonly class GetUsers implements ArchAction
{
public function __construct(private UserModelService $service) {}
public function __invoke(): Collection
{
return $this->service->findByParams(['active' => true]);
}
}The rules are automatically enforced when running PHPStan:
composer analyseFor detailed documentation about the PHPStan rules, see PHPStan Rules Documentation.
For detailed documentation about specific features, see:
- Code Generation - Artisan commands for generating boilerplate code
- PHPStan Rules - Custom architecture rules
- Repository Caching - Automatic caching layer
- Query Builder Methods - Custom query builder methods with PHPStorm support
- Data Builder - Pipeline-based data transformation
- Data Validation - Integrated validation with DTO support
- Model Manager - CRUD operations with batch processing
- Soft Delete - Soft delete support
- Coverage Analysis - Code coverage information
The package includes comprehensive tests with 100% code coverage:
composer test
composer coverageThe package provides a custom exception for unexpected situations:
use Pekral\Arch\Exceptions\ShouldNotHappen;
// Throw exception with reason
throw ShouldNotHappen::because('This should never happen in normal circumstances');This package is under active development. Contributions are welcome! Please see CONTRIBUTING for details.
Note: Since this package is still in development, please check the latest changes before implementing in production environments.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.