DEV Community

Cover image for PHP Enums From the Query String: tryFrom() vs from()
devTalk
devTalk

Posted on Originally published at dev-talk.com

PHP Enums From the Query String: tryFrom() vs from()

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
Enter fullscreen mode Exit fullscreen mode

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.');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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') returned Level::High and 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') returned Level::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);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)