In Laravel, how do you raise a field-level validation error from service or domain code that has no validator rule for the check?
answer
- same exception, built by hand
- ValidationException::withMessages([...])
- field key => message or list
- handler still redirects or returns 422
- errorBag(), redirectTo(), status()
basics
~20 sThrow ValidationException::withMessages(['field' => 'message']). It builds an empty validator, adds the messages under those keys, and is rendered like any validation failure: a redirect back with errors, or a 422 JSON response for JSON requests.
solid answer
~40 sSome checks only become possible deep in a service: the SKU was reserved by another import a moment ago, a coupon ran out, a credential check failed. `ValidationException::withMessages(['coupon' => 'This coupon has expired.'])` builds an empty validator, adds each message (a string or an array of strings) under its key, and returns the exception to throw. Because it is the same `ValidationException`, the exception handler treats it exactly like a failed rule: redirect back with the error under `coupon`, or a 422 JSON body when the client expects JSON. Fluent setters adjust it: `->errorBag('checkout')`, `->redirectTo($url)` and `->status(409)` if a different code is warranted. It keeps error display consistent without inventing a custom exception for each case.
code
php · 20 lines<?php
namespace App\Services;
use App\Models\Import;
use Illuminate\Validation\ValidationException;
class ConfirmImport
{
public function __invoke(Import $import): void
{
if (Import::where('checksum', $import->checksum)->where('status', 'completed')->exists()) {
throw ValidationException::withMessages([
'file' => 'This file has already been imported.',
])->errorBag('confirm');
}
$import->update(['status' => 'queued']);
}
}go deeper
Know you can throw ValidationException::withMessages() to show a custom error next to a field.
Explain how it builds an empty validator, how the handler renders it, and the errorBag/redirectTo/status setters.
Decide where HTTP-shaped errors may be thrown, and convert domain exceptions at the boundary when layers must stay clean.
Set the rule for mapping business-rule failures to user-facing errors consistently across web and API clients.
## The situation Validation rules check the **shape** of input before the business logic runs. Some failures only appear **during** the logic: - A login attempt: the email and password are well-formed, but the credentials do not match. - An import: the SKU was valid when checked, but another process claimed it before the insert. - A checkout: the coupon exists, but the service discovers it is used up. You want the user to see these the same way as ordinary field errors — next to the right field, or in the same JSON structure — without writing a custom exception and a custom renderer for each. ## ValidationException::withMessages() ```php throw ValidationException::withMessages([ 'coupon' => 'This coupon has already been used.', ]); ``` What it does, per the source: 1. Creates an empty validator (`Validator::make([], [])`). 2. For each key, wraps the value with `Arr::wrap()` so a string or a list both work, and adds every message to the validator's error bag under that key. 3. Returns a new `ValidationException` around that validator. Because it is an ordinary `ValidationException`: - The exception handler renders it the same way as a failed rule — redirect back with errors and old input, or a **422** JSON response when the request expects JSON. - It is not reported to the log, like other validation failures. - The exception message is a summary built from the first error, for example "This coupon has already been used." ## Adjusting the exception The exception has fluent setters: | Method | Effect | |---|---| | `->errorBag('checkout')` | put the errors in a named bag for pages with several forms | | `->redirectTo('/cart')` | redirect to a specific URL instead of back | | `->status(409)` | use a different status code for JSON responses than the default 422 | The default status is 422, held in the public `$status` property. ## Where to throw it - **Services called from controllers** — the natural fit; the controller stays free of error mapping. - **Actions or form-level logic** after validation passed. - **Not from queue jobs** that should report bad data as records; there is no user request to render the error to, so it only fails the job attempt. A reasonable pattern is to let domain code throw its own exception (`CouponExhausted`) and convert it at the boundary with `withMessages`, so the domain layer does not depend on HTTP-shaped errors. Throwing `withMessages` directly from a service is simpler and common in Laravel code; pick one convention per codebase. ## Versus the alternatives - `back()->withErrors(['coupon' => '...'])` from a controller does the redirect part manually but does nothing for JSON clients. - `abort(422, '...')` produces a generic HTTP error with no field key, so forms cannot attach it to a field. - A custom exception with its own `render()` works but has to recreate the error format that validation already provides. ## Common mistakes - Using a key that does not match the input name, so the view never shows the message next to the field. - Passing a nested array of arrays per key; each key takes a string or a flat list of strings. - Catching `ValidationException` around the service call and swallowing it, losing the message. ## Worked example: an import confirmation screen After uploading a CSV, the user confirms the import. The service re-checks that the file has not already been imported; if it has, it throws `ValidationException::withMessages(['file' => 'This file was already imported on 3 March.'])`. The browser returns to the confirmation form with the message under the file field, and an API client gets the same message in its 422 body — one line of code for both. ## Checklist - The key matches the input name the form or client uses. - The message is user-facing, not a stack-trace detail. - The throw happens in code that runs during an HTTP request, not in a worker. - A named bag is set when the page has several forms.
- Can one key carry several messages with withMessages()?Yes. Each value is passed through `Arr::wrap()`, so `['file' => ['Too large.', 'Wrong format.']]` adds both messages under `file`. A plain string adds one.
- How would a JSON client see an error thrown with withMessages()?Exactly like a failed rule: the exception handler returns a 422 response containing the message and the errors keyed by field, unless you changed the status with `->status()`. There is no separate format for hand-built validation errors.
saying these in an interview costs you the question
- withMessages() needs a validator instance passed in first
- Hand-built validation errors are rendered as a 500
- abort(422) attaches the message to a form field
- Each key must map to an associative array of rule names