Typed Result pattern for Laravel service-layer operations with named factory methods and specialized result types
composer require philiprehberger/laravel-operation-resultTyped Result pattern for Laravel service-layer operations with named factory methods and specialized result types.
composer require philiprehberger/laravel-operation-result
No service provider registration is needed. The classes are ready to use immediately.
Without a result pattern, service methods either throw exceptions for every failure or return ambiguous booleans/nulls that force controllers to guess what went wrong. Result objects make the contract explicit:
// Without result objects
public function createClient(array $data): Client
{
// Throws on validation, throws on DB error, throws on auth — controller catches them all
}
// With result objects
public function createClient(array $data): OperationResult
{
// Returns a structured result — controller knows exactly what to check
}
| Class | Use Case |
|---|---|
OperationResult | Model CRUD operations (create, update, delete) |
BulkActionResult | Operations on multiple items at once |
CollectionResult | Service methods returning lists or paginated data |
ValidationResult | Data and template validation with errors and warnings |
RateLimitResult | API rate limit checks with HTTP header generation |
UndoResult | Undo operations tracking restored vs failed items |
All classes implement ResultContract and extend the abstract Result base class.
Use when a service method creates, reads, updates, or deletes an Eloquent model.
use PhilipRehberger\OperationResult\OperationResult;
class ClientService
{
public function create(array $data): OperationResult
{
if (!auth()->user()->can('create', Client::class)) {
return OperationResult::unauthorized();
}
$validator = Validator::make($data, ['name' => 'required|string|max:255']);
if ($validator->fails()) {
return OperationResult::validationFailed('Validation failed', $validator->errors()->toArray());
}
$client = Client::create($data);
return OperationResult::created($client);
}
public function update(Client $client, array $data): OperationResult
{
$client->update($data);
return OperationResult::updated($client, 'Client profile updated.');
}
public function delete(int $id): OperationResult
{
$client = Client::find($id);
if (!$client) {
return OperationResult::notFound('Client not found.');
}
$client->delete();
return OperationResult::deleted();
}
}
public function store(StoreClientRequest $request, ClientService $service): JsonResponse
{
$result = $service->create($request->validated());
if ($result->failed()) {
return response()->json($result->toArray(), match ($result->getErrorCode()) {
'UNAUTHORIZED' => 403,
'VALIDATION_FAILED' => 422,
default => 500,
});
}
return response()->json($result->toArray(), 201);
}
| Method | Description |
|---|---|
OperationResult::created($model, $message) | Success — model was created |
OperationResult::updated($model, $message) | Success — model was updated |
OperationResult::deleted($message) | Success — model was deleted |
OperationResult::success($model, $message) | Generic success, model optional |
OperationResult::failure($message, $errorCode, $data) | Generic failure |
OperationResult::notFound($message) | 404-style failure, error code NOT_FOUND |
OperationResult::validationFailed($message, $errors) | Validation failure, error code VALIDATION_FAILED |
OperationResult::unauthorized($message) | Auth failure, error code UNAUTHORIZED |
$result->getModel(); // ?Model
$result->getData(); // array
$result->withData(['key' => 'value']); // returns new instance with merged data
$result->withMessage('New msg'); // returns new instance with updated message
$result->getOrThrow(); // Model|array — throws \RuntimeException on failure
$result->getErrorCode(); // ?string
$result->isNotFound(); // bool
$result->isUnauthorized(); // bool
$result->isValidationFailed(); // bool
$result->toArray(); // array
Use when operating on multiple items at once, such as bulk-deleting, bulk-archiving, or bulk-updating a set of records.
use PhilipRehberger\OperationResult\BulkActionResult;
class BulkClientService
{
public function archiveMany(array $ids): BulkActionResult
{
$processed = 0;
$details = [];
foreach ($ids as $id) {
$client = Client::find($id);
if (!$client) {
$details[] = ['id' => $id, 'success' => false, 'error' => 'Not found'];
continue;
}
$client->update(['status' => 'archived']);
$details[] = ['id' => $id, 'success' => true];
$processed++;
}
$failed = count($ids) - $processed;
if ($failed > 0 && $processed > 0) {
return BulkActionResult::partial($processed, $failed, "{$processed} archived, {$failed} failed.", $details);
}
if ($failed > 0) {
return BulkActionResult::failure('No clients were archived.', null, $details);
}
$undoToken = Str::uuid()->toString();
Cache::put("undo:{$undoToken}", $ids, now()->addMinutes(10));
return BulkActionResult::success($processed, "{$processed} clients archived.", $details, $undoToken);
}
}
public function bulkArchive(BulkArchiveRequest $request, BulkClientService $service): JsonResponse
{
$result = $service->archiveMany($request->input('ids'));
$status = $result->succeeded() ? 200 : 422;
return response()->json($result->toArray(), $status);
}
| Method | Description |
|---|---|
BulkActionResult::success($processed, $message, $details, $undoToken, $undoExpiresAt) | All items processed |
BulkActionResult::partial($processed, $failed, $message, $details, $undoToken, $undoExpiresAt) | Mixed results |
BulkActionResult::failure($message, $errorCode, $details) | Complete failure |
$result->hasFailures(); // bool — true if any items failed
$result->isComplete(); // bool — true if processed > 0 and failed === 0
$result->getFailedIds(); // array — IDs from details where success === false
$result->getSuccessIds(); // array — IDs from details where success === true
$result->canUndo(); // bool — true if undoToken is set
Use when a service method returns a list of items, with or without pagination.
use PhilipRehberger\OperationResult\CollectionResult;
class ProjectService
{
public function listForClient(int $clientId, int $page = 1, int $perPage = 15): CollectionResult
{
$paginator = Project::where('client_id', $clientId)
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
if ($paginator->isEmpty()) {
return CollectionResult::empty('No projects found for this client.');
}
return CollectionResult::paginated(
$paginator->getCollection(),
total: $paginator->total(),
page: $page,
perPage: $perPage
);
}
public function getRecent(): CollectionResult
{
try {
$projects = Project::orderByDesc('updated_at')->limit(10)->get();
return CollectionResult::withItems($projects, $projects->count());
} catch (\Exception $e) {
return CollectionResult::failure('Could not load projects.', 'DB_ERROR');
}
}
}
public function index(Request $request, ProjectService $service): JsonResponse
{
$result = $service->listForClient(
$request->user()->client_id,
$request->integer('page', 1)
);
if ($result->failed()) {
return response()->json(['error' => $result->getMessage()], 500);
}
return response()->json($result->toArray());
}
| Method | Description |
|---|---|
CollectionResult::withItems($items, $total, $message) | Success with a list, no pagination |
CollectionResult::paginated($items, $total, $page, $perPage, $message) | Success with pagination metadata |
CollectionResult::empty($message) | Success with zero items |
CollectionResult::failure($message, $errorCode) | Failure |
$result->getItems(); // Collection|array
$result->getTotal(); // ?int
$result->count(); // int — count of items in this result
$result->isEmpty(); // bool
$result->hasMore(); // bool — true when more pages exist
Use when a service or class validates data, tracking both hard errors (blocking) and soft warnings (advisory).
use PhilipRehberger\OperationResult\ValidationResult;
class InvoiceTemplateValidator
{
public function validate(array $templateData): ValidationResult
{
$errors = [];
$warnings = [];
if (empty($templateData['line_items'])) {
$errors['line_items'] = 'At least one line item is required.';
}
if (!isset($templateData['due_date'])) {
$warnings['due_date'] = 'No due date set; invoice will have no payment deadline.';
}
if (!empty($errors)) {
return ValidationResult::invalid($errors, $warnings);
}
return ValidationResult::valid($warnings);
}
}
public function validateTemplate(Request $request, InvoiceTemplateValidator $validator): JsonResponse
{
$result = $validator->validate($request->all());
$status = $result->isValid() ? 200 : 422;
return response()->json($result->toArray(), $status);
}
| Method | Description |
|---|---|
ValidationResult::valid($warnings) | Passes, optional warnings |
ValidationResult::invalid($errors, $warnings) | Fails with errors, optional warnings |
ValidationResult::failure($message, $errorCode) | Unexpected failure (not a validation error) |
$result->isValid(); // bool
$result->hasErrors(); // bool
$result->hasWarnings(); // bool
$result->getErrors(); // array
$result->getWarnings(); // array
Use when checking or enforcing API rate limits. Provides typed results and generates standard HTTP rate-limit response headers.
use PhilipRehberger\OperationResult\RateLimitResult;
class ApiRateLimiter
{
public function check(string $apiKey, string $scope): RateLimitResult
{
$limit = 1000;
$window = 3600; // 1 hour
$cacheKey = "rate_limit:{$apiKey}:{$scope}";
$resetAt = now()->addHour()->timestamp;
$current = Cache::increment($cacheKey);
if ($current === 1) {
Cache::expire($cacheKey, $window);
}
$remaining = max(0, $limit - $current);
if ($current > $limit) {
$ttl = Cache::ttl($cacheKey);
return RateLimitResult::denied($limit, $resetAt, $ttl);
}
return RateLimitResult::allowed($limit, $remaining, $resetAt);
}
}
public function handle(Request $request, Closure $next): Response
{
$result = $this->rateLimiter->check($request->header('X-API-Key'), 'default');
$response = $result->isDenied()
? response()->json(['error' => $result->getMessage()], 429)
: $next($request);
foreach ($result->getHeaders() as $header => $value) {
$response->headers->set($header, $value);
}
return $response;
}
| Method | Description |
|---|---|
RateLimitResult::allowed($limit, $remaining, $resetAt) | Request is within limit |
RateLimitResult::denied($limit, $resetAt, $retryAfter) | Limit exceeded, error code RATE_LIMITED |
$result->isAllowed(); // bool
$result->isDenied(); // bool
$result->getHeaders(); // array<string, string> — X-RateLimit-* headers, plus Retry-After when denied
Use when reversing a previous bulk operation, tracking how many items were restored successfully vs how many failed.
use PhilipRehberger\OperationResult\UndoResult;
class UndoService
{
public function undo(string $token): UndoResult
{
$ids = Cache::pull("undo:{$token}");
if (!$ids) {
return UndoResult::failure('Undo token not found or has expired.', 'TOKEN_EXPIRED');
}
$restored = 0;
$failed = 0;
foreach ($ids as $id) {
$client = Client::withTrashed()->find($id);
if ($client && $client->restore()) {
$restored++;
} else {
$failed++;
}
}
if ($failed > 0 && $restored > 0) {
return UndoResult::partial($restored, $failed, "{$restored} clients restored, {$failed} could not be undone.");
}
if ($failed > 0) {
return UndoResult::failure('Undo failed for all items.');
}
return UndoResult::success($restored);
}
}
public function undo(string $token, UndoService $service): JsonResponse
{
$result = $service->undo($token);
$status = $result->succeeded() ? 200 : 422;
return response()->json($result->toArray(), $status);
}
| Method | Description |
|---|---|
UndoResult::success($restored, $message) | All items restored |
UndoResult::partial($restored, $failed, $message) | Mixed results |
UndoResult::failure($message, $errorCode) | Complete failure |
$result->hasFailures(); // bool — true if any items could not be restored
All result types implement PhilipRehberger\OperationResult\Contracts\ResultContract:
interface ResultContract
{
public function succeeded(): bool;
public function failed(): bool;
public function getMessage(): string;
public function toArray(): array;
}
Use this interface for type hints when you accept any result type:
public function logResult(ResultContract $result): void
{
Log::info($result->getMessage(), $result->toArray());
}
Every result type includes convenience methods for checking common error codes:
$result = $service->findClient($id);
if ($result->isNotFound()) {
abort(404, $result->getMessage());
}
if ($result->isUnauthorized()) {
abort(403, $result->getMessage());
}
if ($result->isValidationFailed()) {
return back()->withErrors($result->getData()['errors'] ?? []);
}
Use getOrThrow() to extract data from a successful result or throw on failure:
// Returns the model (OperationResult) or null (base Result) on success
$client = $service->findClient($id)->getOrThrow();
// Throws \RuntimeException with the error message on failure
try {
$model = $service->create($data)->getOrThrow();
} catch (\RuntimeException $e) {
Log::error($e->getMessage());
}
Use withMessage() to create a new result instance with a different message:
$result = $service->create($data);
if ($result->succeeded()) {
$result = $result->withMessage('Client was created and synced to CRM.');
}
return response()->json($result->toArray());
| Class | Use Case |
|---|---|
OperationResult | Model CRUD operations (create, update, delete) |
BulkActionResult | Operations on multiple items at once |
CollectionResult | Service methods returning lists or paginated data |
ValidationResult | Data and template validation with errors and warnings |
RateLimitResult | API rate limit checks with HTTP header generation |
UndoResult | Undo operations tracking restored vs failed items |
All classes implement ResultContract: succeeded(), failed(), getMessage(), toArray().
Every result class inherits these methods from Result:
| Method | Returns | Description |
|---|---|---|
succeeded() | bool | True if the operation succeeded |
failed() | bool | True if the operation failed |
getMessage() | string | The result message |
getErrorCode() | ?string | The error code, if any |
isNotFound() | bool | True if error code is NOT_FOUND |
isUnauthorized() | bool | True if error code is UNAUTHORIZED |
isValidationFailed() | bool | True if error code is VALIDATION_FAILED |
getOrThrow() | mixed | Returns data on success, throws \RuntimeException on failure |
withMessage(string $message) | static | Returns a new instance with the updated message |
toArray() | array | Array representation of the result |
composer install
vendor/bin/phpunit
vendor/bin/pint --test
vendor/bin/phpstan analyse
If you find this project useful: