A clean way to safely call your potentially destructive methods in PHP
Matt Kenefick
Posted on August 14, 2021
I’ve been building an application with Laravel 8.0 recently that utilizes a lot of console commands for various housekeeping reasons. For most of these methods, I want to utilize a dry-run
test to deny changes by default.
The first most obvious approach is to use an option that you can check in each of your function calls. That would look like:
public function myMethod() {
if ($this->dryrun) {
return;
}
// Execute destructive code...
}
This puts a little too much pressure on each function to know about what a dry-run
even is, how to check against it, and it couples logic too directly.
The approach I’ve chosen to use instead utilizes PHP’s magic __call()
to help make better determinations; much of Laravel uses this as well.
--
For my commands, I have a BaseCommand.php
file that will ask for the dryrun option, store the value, and provide the magic methods required. Here’s the magic method that I wrote for it:
<?php
// Rest of class...
/**
* Magical CALL method that checks for dynamic method
* names. Allows us to write "safelyMyFunction" but
* reference "myFunction"
*
* @param string $method
* @param mixed $arguments
* @return void
*/
public function __call($method, $arguments)
{
preg_match('/(safely|test)(.*)/', $method, $matches);
// Check if we have any matches
if (isset($matches)) {
$protectedMethod = $matches[1] . 'Call';
$classMethod = lcfirst($matches[2]);
// Check if modified method exists on this class
// e.g. "safelyCall(...)" or "testCall(...)"
if (method_exists($this, $protectedMethod) && method_exists($this, $classMethod)) {
return $this->$protectedMethod($classMethod, $arguments);
}
}
}
The signature __call($method, $arguments)
is provided by PHP.
You can see we’re using a regular expression to check for the words safely
and test
. This gives us the flexibility to prefix more types of calls in the future where we could write: safelyExecuteMethod
or testExecuteMethod
, or potentially queueExecuteMethod
, etc.
We call a method that matches such a pattern, then it continues on to parse it. It generates two things:
- The internal call wrapper, e.g.
safelyCall
,testCall
, orqueueCall
- A camel-case method name, e.g.
myMethod
rather thanMyMethod
We check to make sure that safelyCall
and myMethod
exist on the instance, and if it does, we call it.
Where does safelyCall
come from? It’s a custom method that we write based on the prefixes we want to support. Here’s mine:
<?php
// Rest of class...
/**
* Special call using prefix "safely"
*
* @param string $classMethod
* @param mixed $arguments
* @return void
*/
protected function safelyCall(string $classMethod, $arguments)
{
// Check if it's a dry run
if ($this->dryrun) {
$this->log("🟡 Not executing `$classMethod` on a dry-run.\n");
return false;
}
return call_user_func_array(array($this, $classMethod), $arguments);
}
This is the function that would be called when you prefix safely
and you can see it accepts the $classMethod
you’re trying to call and additional arguments.
It’s important to have this intermediate layer. You can see how we check for the dryrun
here as opposed to in every single method that might want one. It’s a nice little bit of middleware at the class level.
If we put it all together, here’s what we end up with:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
abstract class BaseCommand extends Command
{
/**
* If we are expecting a dry run here
*
* For destructive calls, use --dryrun=false
*
* We are intentionally using "false" explicitly because
* we don't want accidental values like "0" causing
* damaging effects.
*
* @var boolean
*/
protected bool $dryrun = true;
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$dryrun = $this->option('dryrun') === 'false';
// Set options
$this->dryrun = $dryrun ? !$dryrun : $this->dryrun;
}
/**
* Magical CALL method that checks for dynamic method
* names. Allows us to write "safelyMyFunction" but
* reference "myFunction"
*
* @param string $method
* @param mixed $arguments
* @return void
*/
public function __call($method, $arguments)
{
preg_match('/(safely|test)(.*)/', $method, $matches);
// Check if we have any matches
if (isset($matches)) {
$protectedMethod = $matches[1] . 'Call';
$classMethod = lcfirst($matches[2]);
// Check if modified method exists on this class
// e.g. "safelyCall(...)" or "testCall(...)"
if (method_exists($this, $protectedMethod) && method_exists($this, $classMethod)) {
return $this->$protectedMethod($classMethod, $arguments);
}
}
}
/**
* Special call using prefix "safely"
*
* @return void
*/
protected function safelyCall(string $classMethod, $arguments)
{
// Check if it's a dry run
if ($this->dryrun) {
$this->log("🟡 Not executing `$classMethod` on a dry-run.\n");
return false;
}
return call_user_func_array(array($this, $classMethod), $arguments);
}
// ...
}
As you might have noticed, we’re missing a testCall
method above, but you can determine if you want to add that. As mentioned, there’s the regular expression of /(safely|test|whatever|you|want)(.*)/
which would allow you to define signatures for safelyCall
, testCall
, whateverCall
, youCall
, and wantCall
. If the method doesn’t exist, it will just be skipped.
How to use it?
How to use it?
Let’s say we want to run some potentially destructive SQL queries that in order to clean abandoned content. Please note that the following design is for demonstrative purposes.
We can have a function that searches for the content we want to remove and another that will actually remove it. Separating the search and destroy methods provides more flexibility and allows a structure like this to work.
<?php
// Rest of class...
/**
* Search and destroy unused content
*
* @return void
*/
protected function cleanContentTable()
{
$sql = "
SELECT `content`.*
FROM `content`
LEFT JOIN `user` ON `user`.`content_id` = `content`.`id`
WHERE (`user`.`id` IS NULL AND `content`.`type` = 1234)
";
// Determine rows found
$ids = array_column(DB::select($sql), 'id');
// Checks to see if it's a dry run or not first
$this->safelyRemoveContentByIds($ids);
// Does NOT test for dry run, just removes content
// $this->removeContentByIds($ids);
}
/**
* Remove content
*
* @param array $ids
* @return void
*/
protected function removeContentByIds(array $ids = [])
{
// Delete content...
}
You can see that our cleanContentTable
is the primary call that will search for the content, find a list of IDs, then ask to destroy them.
The function that actually destroys them is removeContentByIds
but you’ll notice we’re literally calling something else; we’re actually calling safelyRemoveContentByIds
which doesn’t technically exist in the class.
This is where the magic happens. Since the method doesn’t actually exist, it’s forwarded to __call()
provided by PHP. In our earlier code, we wrote some logic to parse out the method name and delegate further responsibilities.
By prefixing our destructive call with a self-commenting safely
prefix, we’re able to call methods without writing unnecessarily repetitive logic. From this point, you can write whatever type of prefix, suffix, or anything you want to intelligently wrap your method calls with self-documenting code.
But you must remember…
Magic can be very dangerous and is often not the right choice!
Some people use magic to hide poorly written code. It’s akin to asking a child to clean his room and then finding out he just shoved all of his toys in the closet; that’s not clean kiddo. (It’s what I used to do when I was young.)
If you use magic in that way, you’re abusing the system.
I would argue the use of magic in this example is valuable because it’s designed to reduce repetitive code in a language that doesn’t support decorators while extending value to the developers.
There are some frameworks that allow you to decorate functions using comments, but personally, I think that’s an awful idea. Comments should never be allowed to execute code.
Posted on August 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.