laravel-infrastructure maintained by ak279642
Laravel Infrastructure
Reusable Laravel infrastructure for repository-driven applications.
The package provides a reusable repository layer, deterministic and tag-aware caching, automatic Eloquent cache invalidation, query filtering/search/sorting, relation loading, repository-backed validation, operation contexts, generic API responses/exceptions, logging helpers, and transaction boundaries.
Requirements
- PHP 8.2+
- Laravel 10, 11, 12 or 13
- A taggable Laravel cache store such as Redis or Memcached is recommended when repository caching is enabled
Installation
composer require ak279642/laravel-infrastructure
Laravel package discovery registers the service provider automatically.
Publish the optional configuration file when you want to customize package settings:
php artisan vendor:publish --tag=laravel-infrastructure-config
The published file is:
config/laravel-infrastructure.php
Useful environment variables include:
LARAVEL_INFRASTRUCTURE_CACHE_STORE=redis
LARAVEL_INFRASTRUCTURE_CACHE_TTL=300
LARAVEL_INFRASTRUCTURE_LOGGING_ENABLED=true
LARAVEL_INFRASTRUCTURE_LOG_CHANNEL=
LARAVEL_INFRASTRUCTURE_EXCEPTION_TRACE=false
Recommended application architecture
The package is designed for this application flow:
Controller
|
v
Action / Orchestrator <-- transaction boundary
|
v
Service <-- complete business logic
|
v
Repository <-- querying, persistence and cache-aware reads
|
v
Eloquent Model
The package does not force this structure, but it is the intended use:
- Controllers handle HTTP concerns.
- Actions coordinate a use case and own transaction boundaries.
- Services implement complete business operations.
- Repositories own database querying and persistence.
- Models describe Eloquent state/relations and may opt into automatic cache invalidation.
Quick start
1. Make the model cache-aware
Your application models do not need to extend a package base model.
Implement CacheableModel and use InteractsWithCache only on models that should participate in automatic cache invalidation.
<?php
namespace App\Models;
use Ak279642\LaravelInfrastructure\Cache\Concerns\InteractsWithCache;
use Ak279642\LaravelInfrastructure\Contracts\CacheableModel;
use Illuminate\Database\Eloquent\Model;
final class Customer extends Model implements CacheableModel
{
use InteractsWithCache;
protected $fillable = [
'name',
'email',
'status',
'country_id',
];
public function country()
{
return $this->belongsTo(Country::class);
}
public function orders()
{
return $this->hasMany(Order::class);
}
public function scopeActive($query)
{
return $query->where('status', 'active');
}
}
The model concern registers the package cache observer automatically.
When a cache-aware model is created, updated, deleted, restored, or force-deleted, related repository cache tags are invalidated.
2. Create a repository
<?php
namespace App\Repositories;
use Ak279642\LaravelInfrastructure\Cache\CacheManager;
use Ak279642\LaravelInfrastructure\Database\Repositories\BaseRepository;
use Ak279642\LaravelInfrastructure\Validation\ValidationContext;
use App\Models\Customer;
final class CustomerRepository extends BaseRepository
{
protected array $searchable = [
'name',
'email',
'country.name',
];
protected array $allowedFilters = [
'status',
'country_id',
'created_at',
];
protected array $allowedSorts = [
'name',
'email',
'created_at',
];
protected array $allowedRelations = [
'country',
'orders',
];
protected array $defaultRelations = [
'country',
];
protected array $defaultOrder = [
'created_at' => 'desc',
];
public function __construct(
Customer $model,
CacheManager $cache,
?ValidationContext $validationContext = null,
) {
parent::__construct($model, $cache, $validationContext);
}
}
The repository now has the package's standard query, persistence and cache behavior.
Optional package BaseModel
If you want cache, slug and file lifecycle behavior available from one application base model, extend the package base model:
<?php
namespace App\Models;
use Ak279642\LaravelInfrastructure\Models\BaseModel as InfrastructureBaseModel;
abstract class BaseModel extends InfrastructureBaseModel
{
protected function slugOptions(): array
{
return [
'enabled' => true,
'source' => ['name', 'title'],
'column' => 'slug',
'unique' => true,
'regenerate_on_update' => false,
'separator' => '-',
];
}
protected function fileOptions(): array
{
return [
'disk' => 'public',
'delete_on_replace' => true,
'delete_on_delete' => true,
'delete_on_soft_delete' => false,
];
}
}
Then application models can extend your own App\Models\BaseModel.
A copyable example is available at examples/Models/BaseModel.php.
The package base model is optional. You can still extend Laravel's normal Model and use only the concerns you need.
Slug handling
Slug generation is opt-in.
Global defaults live in config/laravel-infrastructure.php:
'slug' => [
'enabled' => false,
'source' => 'name',
'column' => 'slug',
'unique' => true,
'regenerate_on_update' => false,
'separator' => '-',
'scope' => [],
],
Enable/configure it on your application base model or a specific model:
protected function slugOptions(): array
{
return [
'enabled' => true,
'source' => ['name', 'title'],
'column' => 'slug',
'unique' => true,
'regenerate_on_update' => true,
'separator' => '-',
];
}
The source can be one column or a fallback list. The first non-empty source value is used.
Scoped unique slugs
For tenant/organization-specific uniqueness:
protected function slugOptions(): array
{
return array_replace(parent::slugOptions(), [
'scope' => ['organization_id'],
]);
}
Now two organizations can both have acme-limited, while duplicate names inside one organization become acme-limited, acme-limited-1, acme-limited-2.
The slug uniqueness lookup goes through the package repository layer and intentionally bypasses cache so multiple writes in the same operation cannot reuse a stale slug lookup.
A manually supplied non-empty slug is never overwritten. If regenerate_on_update is false, the existing slug remains stable when the source changes. If it is true, changing a configured source regenerates the slug unless you explicitly supplied a slug yourself.
For final race-condition protection, add an appropriate database unique index.
File handling
The package provides FileStorage for storing/deleting files and model lifecycle handling for deleting replaced or removed file paths.
It intentionally does not force an image library. Generic Laravel uploads work without Intervention Image or Livewire.
Store an uploaded file
use Ak279642\LaravelInfrastructure\Files\FileStorage;
$path = $files->store(
file: $request->file('avatar'),
directory: 'customers/avatars',
disk: 'public',
);
A safe UUID filename is generated by default while preserving the extension.
Custom filename:
$path = $files->store(
file: $request->file('contract'),
directory: 'contracts',
disk: 'private',
filename: 'customer-100-contract.pdf',
);
Other helpers:
$files->exists($path, 'public');
$files->delete($path, 'public');
$url = $files->url($path, 'public');
Configure model file attributes
protected function fileAttributes(): array
{
return [
'avatar',
'document_path' => [
'disk' => 'private',
'delete_on_replace' => true,
'delete_on_delete' => true,
'delete_on_soft_delete' => false,
],
];
}
A short string entry uses shared defaults from fileOptions() / package configuration. A keyed entry overrides behavior for that one attribute.
When delete_on_replace is true, the old path is deleted only after the database update succeeds. With soft deletes, files are retained by default until force-delete because delete_on_soft_delete defaults to false.
Recommended file + repository flow
Keep upload/storage work in the service, then let the repository persist the path:
final class UpdateCustomerAvatarService
{
public function __construct(
private FileStorage $files,
private CustomerRepository $customers,
) {}
public function update(Customer $customer, UploadedFile $avatar): Customer
{
$path = $this->files->store(
file: $avatar,
directory: 'customers/avatars',
disk: 'public',
);
return $this->customers->update(
id: $customer,
data: ['avatar' => $path],
refresh: true,
);
}
}
The repository handles persistence; the model file concern removes the previous avatar after the successful update.
See examples/Services/UpdateCustomerAvatarService.php.
Built-in repository methods
Common read methods are cache-aware automatically:
$repository->all();
$repository->find($id);
$repository->findOrFail($id);
$repository->get([
'status' => 'active',
]);
$repository->first([
'email' => 'customer@example.com',
]);
$repository->firstOrFail([
'email' => 'customer@example.com',
]);
$repository->exists([
'email' => 'customer@example.com',
]);
$repository->doesntExist([
'email' => 'customer@example.com',
]);
$repository->count([
'status' => 'active',
]);
$repository->sum('credit_limit', [
'status' => 'active',
]);
$repository->avg('credit_limit');
$repository->min('credit_limit');
$repository->max('credit_limit');
$repository->pluck('name', 'id');
$repository->groupCount('status');
Pagination and streaming methods are also available:
$repository->paginate(
filters: ['status' => 'active'],
perPage: 25,
);
$repository->simplePaginate(perPage: 25);
$repository->cursorPaginate(perPage: 100);
$repository->chunk(500, function ($customers) {
// Process each chunk.
});
foreach ($repository->lazy(1000) as $customer) {
// Low-memory iteration.
}
foreach ($repository->cursor() as $customer) {
// Cursor iteration.
}
Standard write methods:
$customer = $repository->create($data);
$customer = $repository->update(
id: $customerId,
data: $data,
);
$customer = $repository->updateOrCreate(
['email' => $email],
['name' => $name],
);
$repository->delete($customerId);
$repository->forceDelete($customerId);
$repository->restore($customerId);
Repository write methods clear the repository cache after persistence.
Filtering
Only fields listed in $allowedFilters are intended for normal application filtering.
Simple equality:
$customers = $repository->get([
'status' => 'active',
'country_id' => 10,
]);
Operator filters:
$customers = $repository->get([
'created_at' => [
'operator' => 'between',
'value' => ['2026-01-01', '2026-12-31'],
],
'country_id' => [
'operator' => 'in',
'value' => [10, 20, 30],
],
'email' => [
'operator' => 'like',
'value' => '@example.com',
],
]);
Supported operators include:
=
!=
<>
>
>=
<
<=
like
ilike
in
in_or_null
not_in
between
not_between
null
not_null
Nested relation filtering is also supported:
$customers = $repository->get([
'country.code' => 'IN',
]);
Search
Configure searchable fields:
protected array $searchable = [
'name',
'email',
'country.name',
];
Then:
$customers = $repository->get([
'search' => 'Acme India',
]);
Search terms are split on spaces and applied to the configured columns.
You can also provide search columns for a particular repository call:
$customers = $repository->get([
'search' => 'Acme',
'search_columns' => ['name', 'email'],
]);
Sorting
Configure allowed sort columns:
protected array $allowedSorts = [
'name',
'email',
'created_at',
];
Then:
$repository->get([
'sort' => ['name', '-created_at'],
]);
or:
$repository->get([
'sort' => [
'name' => 'asc',
'created_at' => 'desc',
],
]);
A leading - means descending order.
Default ordering can be configured on the repository:
protected array $defaultOrder = [
'created_at' => 'desc',
];
Relations
Configure relations that callers are allowed to request:
protected array $allowedRelations = [
'country',
'orders',
];
Read with relations:
$customer = $repository->findOrFail(
id: 10,
with: ['country', 'orders'],
);
or:
$customers = $repository->get([
'with' => ['country'],
]);
Default relations:
protected array $defaultRelations = [
'country',
];
Load after retrieval:
$repository->load($customer, ['country', 'orders']);
$repository->loadMissing($customer, 'country');
Relation counts/aggregates are also available through repository relation helpers:
$repository
->withCount('orders')
->withSum('orders', 'total')
->withAvg('orders', 'total');
Model scopes
Given an Eloquent model scope:
public function scopeActive($query)
{
return $query->where('status', 'active');
}
Use it through filters:
$customers = $repository->get([
'scopes' => ['active'],
]);
or through the repository query helper:
$repository->scope('active');
Unknown scopes are ignored.
Repository caching
Repository caching is enabled by default.
Default repository TTL:
5 minutes
You can change cache behavior per repository call chain.
Change the TTL
use Ak279642\LaravelInfrastructure\Cache\CacheTtl;
$customers = $repository
->cacheTtl(CacheTtl::MINUTES_10)
->get(['status' => 'active']);
or:
$customers = $repository
->withCache(CacheTtl::HOUR)
->get();
Disable cache
$customers = $repository
->withoutCache()
->get(['status' => 'active']);
Re-enable cache
$customers = $repository
->withCache()
->get();
Cache forever
$customers = $repository
->rememberForever()
->get(['status' => 'active']);
Use forever caching only for data whose invalidation path is reliable.
Add extra cache tags
$customers = $repository
->cacheTags([
'tenant:10',
'customer-directory',
])
->get();
Clear a repository's cache
$repository->clearCache();
This flushes the model tag used by that repository.
Custom repository methods with cache
A complete copyable example is available at examples/Repositories/CustomerRepository.php.
This is the recommended way to cache application-specific repository queries.
Do not use Laravel's Cache facade directly inside the repository unless you intentionally want to bypass the package's repository key/tag conventions.
A subclass of BaseRepository can call the protected cacheRemember() method.
Example: simple custom cached method
<?php
namespace App\Repositories;
use Ak279642\LaravelInfrastructure\Cache\CacheManager;
use Ak279642\LaravelInfrastructure\Cache\CacheTtl;
use Ak279642\LaravelInfrastructure\Database\Repositories\BaseRepository;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Collection;
final class CustomerRepository extends BaseRepository
{
protected array $allowedRelations = [
'country',
'orders',
];
public function __construct(
Customer $model,
CacheManager $cache,
) {
parent::__construct($model, $cache);
}
public function activeForCountry(int $countryId): Collection
{
return $this
->cacheTtl(CacheTtl::MINUTES_10)
->cacheRemember(
operation: 'activeForCountry',
callback: fn () => $this->query()
->where('country_id', $countryId)
->where('status', 'active')
->orderBy('name')
->get(),
params: [
'country_id' => $countryId,
'status' => 'active',
],
);
}
}
Usage:
$customers = $customerRepository->activeForCountry(10);
The generated cache key is deterministic and includes the custom operation plus parameters.
Equivalent calls with the same parameters reuse the same cache entry.
Different parameters generate different entries:
$customerRepository->activeForCountry(10); // one cache entry
$customerRepository->activeForCountry(20); // another cache entry
Example: custom cached method with relations
When your custom query eager-loads relations, pass the relation names in the params array using the with key.
This lets the package include cache dependency tags for cache-aware related models.
public function findWithDashboardData(int $customerId): Customer
{
return $this->cacheRemember(
operation: 'findWithDashboardData',
callback: fn () => $this->query()
->with([
'country',
'orders',
])
->withCount('orders')
->findOrFail($customerId),
params: [
'id' => $customerId,
'with' => [
'country',
'orders',
],
],
);
}
If Country and Order also implement CacheableModel, their dependency tags can participate in invalidation.
Example: cached aggregate custom method
public function activeCreditTotal(int $countryId): float
{
return (float) $this->cacheRemember(
operation: 'activeCreditTotal',
callback: fn () => $this->query()
->where('country_id', $countryId)
->where('status', 'active')
->sum('credit_limit'),
params: [
'country_id' => $countryId,
'status' => 'active',
],
);
}
Example: custom method with unordered parameters
If order should not change the meaning of an array parameter, wrap the values with CacheKey::unordered().
use Ak279642\LaravelInfrastructure\Cache\CacheKey;
public function byIds(array $ids): Collection
{
return $this->cacheRemember(
operation: 'byIds',
callback: fn () => $this->query()
->whereIn('id', $ids)
->get(),
params: [
'ids' => CacheKey::unordered($ids),
],
);
}
These calls now resolve to the same cache key:
$repository->byIds([1, 2, 3]);
$repository->byIds([3, 1, 2]);
Custom write methods and cache invalidation
If the custom method uses the repository's normal write methods, cache clearing is already handled:
public function activate(int $customerId): Customer
{
return $this->update(
id: $customerId,
data: ['status' => 'active'],
);
}
If you implement a completely custom write query, invalidate the repository cache after the write:
public function markCountryCustomersInactive(int $countryId): int
{
$affected = $this->query()
->where('country_id', $countryId)
->update([
'status' => 'inactive',
]);
if ($affected > 0) {
$this->clearCache();
}
return $affected;
}
For model-by-model saves on models using InteractsWithCache, the model observer also invalidates cache tags. Calling clearCache() explicitly for a custom bulk/database write is still the safest repository pattern.
Advanced: completely custom cache keys and tags
For unusual repository requirements, subclasses can access the package cache manager through getCacheManager().
use Ak279642\LaravelInfrastructure\Cache\CacheKey;
use Ak279642\LaravelInfrastructure\Cache\CacheTag;
use Ak279642\LaravelInfrastructure\Cache\CacheTtl;
public function dashboardSummary(int $countryId): array
{
$key = CacheKey::make('customers.dashboard-summary', [
'country_id' => $countryId,
]);
$tags = CacheTag::tags(
Customer::cacheTag(),
'customer-dashboard',
'country:'.$countryId,
);
return $this->getCacheManager()->remember(
key: $key,
ttl: CacheTtl::MINUTES_5,
callback: fn () => [
'active' => $this->query()
->where('country_id', $countryId)
->where('status', 'active')
->count(),
'inactive' => $this->query()
->where('country_id', $countryId)
->where('status', 'inactive')
->count(),
],
tags: $tags,
);
}
Prefer cacheRemember() for normal repository methods because it automatically follows the package's repository key and tag strategy.
Use direct CacheManager access only when you actually need a separate cache namespace/tag design.
Cache keys
Use CacheKey::make() to generate deterministic cache keys.
use Ak279642\LaravelInfrastructure\Cache\CacheKey;
$key = CacheKey::make('customers.list', [
'status' => 'active',
'country_id' => 10,
]);
Parameter ordering does not change the resulting key.
CacheKey::make('customers.list', [
'status' => 'active',
'country_id' => 10,
]);
CacheKey::make('customers.list', [
'country_id' => 10,
'status' => 'active',
]);
Both generate the same deterministic key.
Use a readable key when debugging is more important than compactness:
$key = CacheKey::readable('customers.list', [
'status' => 'active',
]);
Cache tags
Useful helpers:
use Ak279642\LaravelInfrastructure\Cache\CacheTag;
$modelTag = CacheTag::fromModel(Customer::class);
$entityTags = CacheTag::model(
Customer::cacheTag(),
10,
);
$tags = CacheTag::tags(
'customers',
'tenant:10',
['directory', 'active'],
);
$merged = CacheTag::merge(
['customers'],
['tenant:10'],
);
Cache TTL constants
use Ak279642\LaravelInfrastructure\Cache\CacheTtl;
CacheTtl::SECONDS_30;
CacheTtl::MINUTE;
CacheTtl::MINUTES_5;
CacheTtl::MINUTES_10;
CacheTtl::MINUTES_30;
CacheTtl::HOUR;
CacheTtl::HOURS_6;
CacheTtl::DAY;
CacheTtl::DAYS_7;
CacheTtl::MONTH;
CacheTtl::YEAR;
CacheTtl::SHORT; // 5 minutes
CacheTtl::MEDIUM; // 1 hour
CacheTtl::LONG; // 6 hours
CacheTtl::WEEK; // 7 days
Cache-store safety
Repository invalidation is tag-based.
Laravel cache stores do not all support tags.
When a repository read resolves cache tags but the configured store does not support tags, the package intentionally bypasses repository caching rather than create entries that cannot be invalidated reliably.
For applications that depend on repository caching, use a taggable cache store such as Redis or Memcached.
Example:
CACHE_STORE=redis
LARAVEL_INFRASTRUCTURE_CACHE_STORE=redis
Correctness is preferred over stale cache.
Bulk repository operations
The package provides model-aware bulk methods:
$repository->bulkUpdate(
data: ['status' => 'inactive'],
filters: ['country_id' => 10],
);
$repository->bulkDelete([
'status' => 'inactive',
]);
$repository->bulkRestore([
'country_id' => 10,
]);
$repository->bulkForceDelete([
'status' => 'deleted',
]);
These methods process models individually so normal Eloquent lifecycle hooks/observers can run.
Duplicate and existence helpers
Useful repository methods for business validation:
$duplicate = $repository->findDuplicate([
'email' => $email,
]);
$duplicate = $repository->findDuplicate(
fields: [
'email' => $email,
'phone' => $phone,
],
ignore: $customerId,
where: [
'tenant_id' => $tenantId,
],
);
$customer = $repository->findWhere(
id: $customerId,
where: [
'tenant_id' => $tenantId,
],
with: ['country'],
);
$customers = $repository->findWhereIn(
field: 'id',
values: [1, 2, 3],
where: [
'tenant_id' => $tenantId,
],
);
Repository-backed validation
The package can perform repository-level uniqueness/existence checks and reuse resolved models through ValidationContext.
Example:
use Ak279642\LaravelInfrastructure\Validation\RepositoryValidationRule;
use Ak279642\LaravelInfrastructure\Validation\RepositoryValidationService;
final class CreateCustomerService
{
public function __construct(
private RepositoryValidationService $validation,
) {}
public function create(array $data): void
{
$this->validation->validate([
new RepositoryValidationRule(
repository: CustomerRepository::class,
unique: ['email'],
),
new RepositoryValidationRule(
repository: CountryRepository::class,
exists: ['country_id'],
resolve: [
[
'field' => 'country_id',
'with' => [],
],
],
),
], $data);
// Continue the complete business operation...
}
}
Validation failures throw the package ValidationException.
Repository-aware FormRequest validation
Use RepositoryFormRequest when normal Laravel validation must also verify data through repositories and reuse the loaded models later in the same request.
The flow is:
FormRequest rules()
|
v
normal Laravel validation
|
| only if successful
v
repositoryValidationRules()
|
v
RepositoryValidationService
|
+--> repository existence/uniqueness checks
+--> optional relation loading
+--> ValidationContext aliases
|
v
Controller / Action / Service
Repository queries are not executed if normal FormRequest validation already failed.
Example FormRequest
use Ak279642\LaravelInfrastructure\Http\Requests\RepositoryFormRequest;
use Ak279642\LaravelInfrastructure\Validation\RepositoryValidationRule;
final class StoreInvoiceRequest extends RepositoryFormRequest
{
public function rules(): array
{
return [
'organization_id' => ['required', 'integer'],
'customer_id' => ['required', 'integer'],
'product_ids' => ['required', 'array', 'min:1'],
'product_ids.*' => ['integer'],
];
}
protected function repositoryValidationRules(): array
{
return [
new RepositoryValidationRule(
repository: CustomerRepository::class,
exists: [
'customer_id' => [
'where' => [
'organization_id' => 'organization_id',
],
],
],
resolve: [
[
'field' => 'customer_id',
'as' => 'customer',
'with' => ['organization'],
],
],
),
new RepositoryValidationRule(
repository: ProductRepository::class,
existsIn: [
'field' => 'product_ids',
'column' => 'id',
'values' => 'product_ids',
'where' => [
'organization_id' => 'organization_id',
],
],
resolve: [
[
'field' => 'product_ids',
'as' => 'products',
'with' => ['tax'],
],
],
),
];
}
}
See examples/Http/Requests/StoreInvoiceRequest.php.
Repository uniqueness validation
new RepositoryValidationRule(
repository: CustomerRepository::class,
unique: ['email'],
);
For update validation:
new RepositoryValidationRule(
repository: CustomerRepository::class,
unique: ['email'],
ignore: $this->route('customer')->id,
);
Scoped uniqueness:
new RepositoryValidationRule(
repository: CustomerRepository::class,
unique: ['email'],
where: [
'organization_id' => 'organization_id',
],
);
A string value in where is resolved from request data when that request field exists.
Resolve a validated model
resolve: [
[
'field' => 'customer_id',
'as' => 'customer',
'with' => ['organization'],
],
],
After validation:
$customer = $request->resolvedModel(
'customer',
Customer::class,
);
Resolve a collection
existsIn: [
'field' => 'product_ids',
'column' => 'id',
'values' => 'product_ids',
],
resolve: [
[
'field' => 'product_ids',
'as' => 'products',
'with' => ['tax'],
],
],
Then:
$products = $request->resolvedCollection(
'products',
Product::class,
);
Invalid IDs are returned as normal FormRequest validation errors with HTTP 422 behavior.
Use resolved data in a Service
Inject the scoped ValidationContext:
final class CreateInvoiceService
{
public function __construct(
private ValidationContext $validationContext,
private CustomerRepository $customers,
) {}
public function create(array $data): void
{
$customer = $this->validationContext->requireModel(
'customer',
Customer::class,
);
$products = $this->validationContext->requireCollection(
'products',
Product::class,
);
// Continue business logic without repeating validation queries.
}
}
Repository automatically reuses resolved models
A service can also continue using its repository normally:
$customer = $this->customers->findOrFail(
$data['customer_id'],
);
BaseRepository first searches the scoped validation context by model class + primary key. If the same customer was resolved during FormRequest validation, the repository returns that existing model instance instead of querying the database again.
This changes the common pattern:
FormRequest checks customer exists -> query 1
Service loads customer -> query 2
Repository loads customer again -> query 3
into one repository lookup during validation, with the loaded data reused afterward.
See examples/Services/CreateInvoiceService.php.
ValidationContext API
$context->get('customer');
$context->getModel('customer');
$context->requireModel('customer', Customer::class);
$context->getCollection('products');
$context->requireCollection('products', Product::class);
$context->findModel(Customer::class, $customerId);
$context->has('customer');
$context->all();
Aliases also handle multiple instances of the same model class cleanly, such as billing_address and shipping_address, while the internal class+ID index still lets repositories reuse each resolved model.
Operation context
OperationContext is an immutable key/value context for passing already-known operation data without introducing global state.
use Ak279642\LaravelInfrastructure\Context\OperationContext;
$context = new OperationContext([
'tenant_id' => 10,
'requested_by' => $user->id,
]);
$tenantId = $context->get('tenant_id');
$userId = $context->getOrNull('requested_by');
$all = $context->all();
Calling get() for a missing key throws an InvalidArgumentException.
Use getOrNull() for optional values.
Transaction boundaries
The package exposes TransactionManager.
Transactions are intended to live in actions/orchestrators rather than repositories or business services.
use Ak279642\LaravelInfrastructure\Contracts\TransactionManager;
final class CreateInvoiceAction
{
public function __construct(
private CreateInvoiceService $service,
private TransactionManager $transactions,
) {}
public function execute(array $data): Invoice
{
return $this->transactions->run(
fn () => $this->service->create($data),
);
}
}
That keeps this flow clear:
Action owns transaction
Service owns business logic
Repository owns data access
API responses
Message response
use Ak279642\LaravelInfrastructure\Http\Responses\MessageResponse;
return MessageResponse::make('Customer deleted.');
Response:
{
"success": true,
"message": "Customer deleted."
}
Resource response
use Ak279642\LaravelInfrastructure\Http\Responses\ResourceResponse;
return ResourceResponse::make(
resource: new CustomerResource($customer),
message: 'Customer loaded.',
);
Paginated Laravel resource collections receive a separate pagination object automatically.
Exceptions
Generic package exceptions include:
AccessForbiddenException
BusinessLogicException
ConflictException
HttpMethodNotAllowedException
HttpRequestException
InternalServerException
NotFoundException
RouteNotFoundException
ServiceUnavailableException
TooManyRequestsException
UnauthorizedException
ValidationException
Example:
use Ak279642\LaravelInfrastructure\Exceptions\BusinessLogicException;
throw new BusinessLogicException(
message: 'Customer cannot be deactivated while invoices are pending.',
);
Logging
Use CustomLog for application/domain-aware logging with sensitive-value sanitization.
use Ak279642\LaravelInfrastructure\Logging\CustomLog;
use Ak279642\LaravelInfrastructure\Logging\LogDomain;
CustomLog::info(
'Customer created.',
[
'customer_id' => $customer->id,
'email' => $customer->email,
],
LogDomain::APPLICATION,
);
Exceptions:
try {
// Operation...
} catch (Throwable $e) {
CustomLog::exception(
exception: $e,
context: [
'customer_id' => $customerId,
],
);
throw $e;
}
Sensitive keys such as passwords, tokens, API keys, secrets and authorization headers are redacted recursively.
Logging can be disabled globally or per domain through the package configuration.
Package boundaries
This package intentionally does not provide application/domain features such as:
- Admin/RBAC implementation
- User/business models
- Livewire screens
- application routes
- business migrations
- image transformation/resizing workflows (generic file storage/lifecycle handling is included)
- domain seeders
- application-specific authentication
- request-log UI
It also has no dependency on the host application's App\ namespace.
The goal is reusable infrastructure, not a starter application.
Testing
Run the package tests:
composer test
Validate the Composer package:
composer validate --strict
The GitHub Actions compatibility matrix tests supported PHP/Laravel combinations and includes an architecture test that prevents accidental application App\ dependencies.
License
MIT. See LICENSE.