You add ?status=paid filtering to an endpoint, write Status::from($_GET['status']), and it works. Then somebody requests ?status=PAID or ?status=refunded and the page returns a 500, because from() throws on any value it does not recognise. The fix everyone gives is "use tryFrom()", and it is correct for string-backed enums. For int-backed enums it is only half the story, and I tested exactly where it breaks on PHP 8.3.
The basic difference
Both methods exist on backed enums. from() returns the case or throws a ValueError. tryFrom() returns the case or null:
enum Status: string
{
case Paid = 'paid';
case Pending = 'pending';
}
Status::from('paid'); // Status::Paid
Status::from('PAID'); // ValueError: "PAID" is not a valid backing value for enum Status
Status::tryFrom('PAID'); // null
Status::tryFrom('refunded'); // null
Status::tryFrom(''); // null
Status::tryFrom(null); // null
Matching is exact and case-sensitive, so PAID is not paid. If you want to accept both, normalise before the lookup with strtolower().
The usual pattern turns invalid input into a default, or into a clean 422:
$status = Status::tryFrom($request->query('status', '')) ?? Status::Pending;
// or reject it explicitly
$status = Status::tryFrom($request->query('status', ''))
?? abort(422, 'Unknown status.');
Use from() when an invalid value is a bug in your own code, such as reading a value you wrote to the database yourself. A ValueError there is the right outcome, because it means your data is corrupt, and you want it loud.
The catch: tryFrom() can still throw
The name suggests it never fails. It does not. It returns null for a value of the right type that matches no case, but it still enforces the parameter type. With an int-backed enum, a query-string value is a string, and PHP has to convert it:
enum Level: int
{
case Low = 1;
case High = 2;
}
Level::tryFrom('2'); // Level::High (numeric string is coerced)
Level::tryFrom('-1'); // null
Level::tryFrom('abc'); // TypeError: Level::tryFrom(): Argument #1 ($value) must be of type int, string given
Level::tryFrom(''); // TypeError
So ?level=abc gives a TypeError, which is an uncaught error and a 500, the exact thing you were trying to avoid. Two more results from the same run are worth knowing:
-
Level::tryFrom('2.5')returnedLevel::Highand emitted a deprecation (Implicit conversion from float-string "2.5" to int loses precision). A fractional value is silently truncated to 2. -
Level::tryFrom('1e0')returnedLevel::Low, because the exponent form is a valid numeric string. Surprising, but consistent with PHP's normal coercion rules.
Under declare(strict_types=1) the call with the string '2' also throws a TypeError, since no coercion is allowed. Query values are always strings, so convert them first.
A safe helper for int-backed enums
Validate that the input is an integer, then look it up:
function levelFrom(mixed $value): ?Level
{
$int = filter_var($value, FILTER_VALIDATE_INT);
return $int === false ? null : Level::tryFrom($int);
}
I ran this against a set of inputs. '2' and ' 2' returned Level::High. '2.5', 'abc', '', '1e0', null and ['2'] all returned null, with no errors or deprecations. It also covers the array case, which breaks string enums too: ?status[]=paid reaches your code as an array, and Status::tryFrom(['paid']) throws TypeError: ... must be of type string|int, array given.
In Laravel
The framework already handles most of this, with one exception. The Rule::enum() validation rule calls tryFrom() inside a try/catch (TypeError) block (visible in Illuminate\Validation\Rules\Enum), so a bad value fails validation instead of crashing. Validate first, then convert:
use Illuminate\Validation\Rule;
$data = $request->validate([
'status' => ['sometimes', Rule::enum(Status::class)],
'level' => ['sometimes', Rule::enum(Level::class)],
]);
$status = isset($data['status']) ? Status::from($data['status']) : null;
After validation passes, from() is safe, and a failure is a real bug. The exception is $request->enum('level', Level::class). Reading the framework source, it calls $enumClass::tryFrom(...) with no try/catch, so for an int-backed enum I would expect ?level=abc to throw. I confirmed the PHP behaviour above but did not run that exact helper in a full Laravel app, so test it before relying on it. For string-backed enums it behaves as you would hope. Implicit route binding to a backed enum casts the URL segment to a string and uses tryFrom() too, so an unknown value there is a 404, not a 500.
Quick reference
| Situation | Use |
|---|---|
| String-backed enum, untrusted input |
tryFrom() with a default or 422 |
| Int-backed enum, untrusted input | Check with filter_var(..., FILTER_VALIDATE_INT), then tryFrom()
|
| Laravel request |
Rule::enum() first, then from()
|
| Values you wrote yourself (database, config) |
from(), so corruption fails loudly |
Frequently asked questions
Does tryFrom() work on pure enums?
No. Both from() and tryFrom() exist only on backed enums. A pure enum, which has no : string or : int type, has neither method.
Should I catch ValueError instead of using tryFrom()?
You can, but tryFrom() says what you mean and avoids exceptions for normal bad input. Catching ValueError still would not cover the TypeError case.
How do I accept case-insensitive input?
Normalise before the lookup, for example Status::tryFrom(strtolower($value)), as long as your backing values are lowercase.
Originally published on DEV Talk.
Top comments (0)