alt-three/validator
Alt Three Validator is a lightweight Laravel package that extends the built-in validator with a hash rule. Use it to verify that an input matches a stored hash, such as checking a password against a hashed value, with simple rule syntax in your validation rules.
alt-three/validator is a very small wrapper around Laravel's Validator factory that answers one question: "how do I validate an array of input from anywhere in my app — not just a controller — and fail loudly with a message bag?" Instead of returning a Validator instance and making every caller check ->fails(), the package's trait throws a package-specific ValidationException carrying the MessageBag.
Minimal steps:
illuminate/* constraints target Laravel 5.x/6.x. On a modern app, confirm what you're actually getting:
composer require alt-three/validator
composer why-not alt-three/validator # why did it resolve to an old version / fail?
If it conflicts with Laravel 9+, stop here — see Gotchas for the two-line native replacement.extra.laravel.providers). Otherwise add it to config/app.php under providers.vendor/alt-three/validator/src is small enough to read in five minutes, and you should: the exact trait name (ValidationTrait), method signature, and exception API are the whole contract. Don't rely on blog posts for this package.use AltThree\Validator\ValidationTrait;
class IncidentController extends Controller
{
use ValidationTrait;
public function store(Request $request)
{
$input = $request->only(['name', 'status', 'message']);
$this->validate($input, [
'name' => 'required|string|max:255',
'status' => 'required|integer|between:1,4',
'message' => 'required|string',
]);
// input is now trusted; no if ($validator->fails()) branch needed
}
}
app/Exceptions/Handler.php so a thrown ValidationException becomes a 422 JSON body or a redirect-back-with-errors, exactly like Laravel's own validation failure:
public function render($request, Throwable $e)
{
if ($e instanceof \AltThree\Validator\ValidationException) {
if ($request->expectsJson()) {
return response()->json(['message' => $e->getMessage(), 'errors' => $e->getMessageBag()], 422);
}
return redirect()->back()->withInput()->withErrors($e->getMessageBag());
}
return parent::render($request, $e);
}
Until you do this, every validation failure is a 500. This is the single most common "it doesn't work" report.The payoff case: validation that happens outside an HTTP action — an importer, a console command, a queued job, a domain service. $request->validate() can't help you there; a thrown exception can.
Trait on the base controller. Put use ValidationTrait; on App\Http\Controllers\Controller so every controller gets a consistent $this->validate($input, $rules, $messages, $customAttributes) and no one hand-rolls the fails() check. Keeping the $messages/$customAttributes arguments available per-call is useful for one-off field labels.
Explicit input arrays, not $request->all(). The wrapper validates what you hand it but does not strip anything. Always pass $request->only([...]) (or safe()->only([...]) on newer Laravel) so the validated key set and the persisted key set are the same list. This is your whitelist.
Exception-as-control-flow in services. The strongest pattern is to push validation down into a service/job and let it throw, then handle the failure once at the boundary:
final class ImportIncidents
{
use ValidationTrait;
public function handle(array $rows): Collection
{
return collect($rows)->map(function (array $row, int $i) {
try {
$this->validate($row, ['name' => 'required|string', 'status' => 'required|integer']);
} catch (ValidationException $e) {
throw new ImportFailed("Row {$i}: ".$e->getMessageBag()->first('name'));
}
return Incident::create($row);
});
}
}
This is where the package earns its keep: catch, log the bag, and continue/decorate — you don't need a controller to validate.
Message-bag consumption. Every consumer should go through the bag, not the message string: ->all(), ->getMessages(), ->first('email'), ->has('email'). For nested rules (items.*.sku) the keys come back dot-notated, so group them before rendering:
$grouped = collect($e->getMessageBag()->getMessages())
->mapWithKeys(fn ($msgs, $key) => [strtok($key, '.') => $msgs]);
Reuse the Laravel pipeline. The wrapper delegates to the bound validator factory, so everything you already use still works: Rule::unique(...), Rule::in(...), Validator::extend(...) custom rules, after hooks, and the translator's locale. You are only changing how failure is reported, not how rules are evaluated. If a rule works with $request->validate(), it works here.
Testing. In unit tests, assert on the exception rather than the response:
$this->expectException(ValidationException::class);
(new StoreIncident)->handle(['name' => '']);
For HTTP tests, withoutExceptionHandling() surfaces the raw exception; with the handler wired, assert assertStatus(422) + assertJsonValidationErrors(['name']), which keeps your tests portable if you later swap to FormRequests.
Migration-friendly shim. If you're moving toward FormRequest / $request->validate(), define your own thin interface (e.g. ProvidesValidationErrors::errors()) and adapt both the package exception and Illuminate\Validation\ValidationException to it in the handler. Then swapping per-endpoint is a no-op for the response layer.
throw ValidationException::withMessages($validator->errors()->getMessages()) (Laravel 5.8+) or simply Validator::make($input, $rules)->validate(), which throws Laravel's own ValidationException from anywhere, not just controllers. If you only need this behaviour, that's a two-line replacement and no dependency.ValidationException classes. The package's exception and Illuminate\Validation\ValidationException are distinct and have different APIs (getMessageBag() vs. ->errors() / public $validator). Your handler must catch both during any migration, or failures will 500 intermittently depending on which code path threw.Accept: application/json and browser mode before writing more validation.ConvertEmptyStringsToNull changes rule semantics. An empty form field arrives as null at the validator, so nullable|email passes while required|email fails, and '' vs null behaves differently in sometimes. Validate the same payload shape you persist.dd() after $this->validate(...) never runs. Temporarily wrap in try { ... } catch (ValidationException $e) { dd($e->getMessageBag()); }, or log $e->getMessageBag()->all() — the exception message alone is often just "The given data was invalid."$messages array is per-call only; there's no config file. For app-wide messages, use lang/*/validation.php (the underlying translator) rather than editing call sites.App\Support\Validation and deleting the dependency is a legitimate, low-risk move here — you keep the ergonomics, drop an unmaintained package, and regain control of the exception type. Do this before you invest in the package further.composer update will keep resolving the same version forever while the rest of the app moves on. Add a CI check (composer outdated --direct or composer why alt-three/validator) so the pin is a visible decision rather than an archaeological surprise six months from now.How can I help you explore Laravel packages today?