PHP Match and Enums.
4 minutes
Match and Enums have become a happy place for me writing modern PHP. For those that don’t know, the match
expression was added in PHP 8.0 as an alternative to switch
. As per the docs:
The match
expression is similar to a switch
statement but has some key differences:
A
match
arm compares values strictly (===
) instead of loosely as the switch statement does.A
match
expression returns a value.match
arms do not fall-through to later cases the wayswitch
statements do.A
match
expression must be exhaustive.
Enums were added in PHP 8.1 “intended to provide a way to define a closed set of possible values for a type” and they can be plain:
1match ($file->type) {2 FileType::Video => $this->handleVideo($file),3 FileType::Image => $this->handleImage($file),4 FileType::Document => $this->handleDocument($file),5 default => throw new RuntimeException('Unknown file type'),6};
1enum DoorState {2 case Open;3 case Closed;4 case Locked;5}
Or ‘backed’ with integer or string values:
1enum DoorState: int { 2 case Open = 1; 3 case Closed = 2; 4 case Locked = 3; 5} 6 7// DoorState::Open->value returns 1 8enum DoorState: string { 9 case Open = 'open';10 case Closed = 'closed';11 case Locked = 'locked';12}13 14// DoorState::Open->value returns 2
This gives us more options for encapsulating state and, with PHP’s type system improvements, we can pass around enum values rather than integer and string literals and we get some nice hand holding from our tools:
1// bad 2function dont_do_this(string $doorState) { ... } 3 4// this value has a typo but will still run 5dont_do_this('closet'); 6 7// lovely 8function do_this(DoorState $state) { ... } 9 10// this typo will make your IDE shout at you "Constant 'Closet' not found in DoorState"11do_this(DoorState::Closet) ;
Playing nicely together
As Match expressions are exhaustive and Enums are a closed set of values, if we don’t handle all the cases like this:
1enum DoorState { 2 case Open; 3 case Closed; 4 case Locked; 5} 6 7final class Door { 8 public DoorState $state; 9 public function checkState(): string10 {11 return match ($this->state) {12 DoorState::Open => 'is open',13 DoorState::Closed => 'is closed',14 // doh - we forgot to handle locked!15 };16 }17}
Again our IDE / Static Analysis will shout at us “Match expression does not handle remaining value: DoorState::Locked”. You can fix this by handling the missing case or adding a default arm:
1match ($this-state) {2 DoorState::Open => 'is open',3 DoorState::Closed => 'is closed',4 default => 'the door is neither open nor closed',5};
Always another thing to learn
These two features have been in PHP for awhile so using them has become commonplace for me, however there’s always something new to learn in programming. In a recent project with a multi-step upload where each step will call different actions depending on the file type I started using this approach where a related action could be handled by another method on the action class:
sjksdfkds“Job chaining allows you to specify a list of queued jobs that should be run in sequence after the primary job has executed successfully”
This works but we love writing tests for our code right? These actions can be quite complicated and testing individual class methods can require a lot of setup related to other things on that class that a unit test for that specific method isn’t actually testing. For example we don’t want to fake a file or file upload in a unit test that is only testing specific data flows from the bigger upload process. So it was better to refactor to instantiating separate actions in each arm instead like this:
1match ($file->type) {2 FileType::Video => (new HandleVideoAction)->execute($file),3 FileType::Image => (new HandleImageAction)->execute($file),4 FileType::Document => (new HandleDocumentAction)->execute($file),5 default => throw new RuntimeException('Unknown file type'),6};
Now we can have simpler unit tests for the separate action classes, nice. But… in some cases I wanted to do more before calling another action. For example, create a DTO from validated file data specific to the action to process and save it, something like:
1if ($fileType === FileType::Video) {2 // build dto3 $dto = StoreVideoMetadataDTO::fromRequestData($request->validated());4 // pass dto to action5 $result = (new SaveVideoMetadataAction))->execute($dto);6}
As PHP doesn’t [yet] support multi-line short closures I didn’t think it was possible to inline this in the match arm. I was wrong as PHP has supported Immediately Invoked Function Execution aka IFFE (Iffy!) since PHP 7, so we can combine this functionality to do this:
1match ($file->type) {2 FileType::Video => (function ($request) {3 $dto = StoreVideoMetadataDTO::fromRequestData($request->validated());4 return (new SaveVideoMetadataAction))->execute($dto);5 })($request),6 // handle other cases...7};
Whilst the syntax is a little verbose, this feels like a really solution nice to me and great to have another option to reach for.
Wishful thinking
That being said I do still hope that some day we will be able to do this:
1// potential multi-line short closure syntax2match ($file->type) {3 FileType::Video => fn () => {4 $dto = StoreVideoMetadataDTO::fromRequestData($request->validated());5 return (new SaveVideoMetadataAction))->execute($dto);6 },7 // handle other cases...8};
Or even this:
1// simple scope block syntax2match ($file->type) {3 FileType::Video => {4 $dto = StoreVideoMetadataDTO::fromRequestData($request->validated());5 return (new SaveVideoMetadataAction))->execute($dto);6 },7 // handle other cases...8};
Blurred lines
As much as I love the Match + Enum combination I recently found a case where I preferred not to use it. Here we have a Laravel collection and are filtering it. The filter method will keep the values that return true from the callback:
1$filtered = $collection->filter(function (string $value): bool {2 return match(true) {3 $value === FeatureFlagEnum::OptionOne && FeatureFlagEnum::OptionOne->isNotActive(),4 $value === FeatureFlagEnum::OptionTwo && FeatureFlagEnum::OptionTwo->isNotActive() => false,5 default => true,6 };7});
It is possible to group the conditions that will return false by comma separating them but this felt like a step too far as more conditions and more complicated conditions may have to be added. Therefore I fell back to using a standard guard clause like this:
1$filtered = $collection->filter(function (string $value): bool {2 if (3 $value === FeatureFlagEnum::OptionOne && FeatureFlagEnum::OptionOne->isNotActive()4 || $value === FeatureFlagEnum::OptionTwo && FeatureFlagEnum::OptionTwo->isNotActive()5 ) {6 return false;7 }8 return true;9});
What do you think? What are your favourite ways to use Enums and Match in PHP? What’s your favourite modern PHP feature?
Looking to start your next Laravel project?
We’re a passionate, happy team and we care about creating top-quality web applications that stand out from the crowd. Let our skilled development team work with you to bring your ideas to life! Get in touch today and let’s get started!