When (NOT) To Use Static Methods in Laravel/PHP? Practical Examples.

When (NOT) To Use Static Methods in Laravel/PHP? Practical Examples.
Admin
Sunday, July 2, 2023 9 mins to read
Share
When (NOT) To Use Static Methods in Laravel/PHP? Practical Examples.

This tutorial is produced in both video format above and text format below.


Experienced developers often advise avoiding static methods in PHP classes. In this tutorial, let me give you a few practical Laravel/PHP examples behind this advice. When are static methods ok, and when it's best to avoid them?


Independent Static Methods in Simple Classes Are OK

Let's start with an example where static methods can be absolutely fine. Imagine the Controller that uses a Service to store data:

use App\Services\PropertyService;
use App\Http\Requests\StorePropertyRequest;
 
class PropertyController extends Controller
{
public function store(StorePropertyRequest $request)
{
PropertyService::store($request->validated());
 
// ... more logic and return
}
}

Why couldn't that store() method in the service be static? It can be!

app/Services/PropertyService.php:

namespace App\Services;
 
use App\Models\Property;
 
class PropertyService {
 
public static function store(array $propertyData): Property
{
$property = Property::create($propertyData);
 
// ... maybe some more logic
 
return $property;
}
 
}

And you know what? This code is totally fine. There's absolutely nothing wrong in using a Static method here because it's "stateless" - it doesn't depend on any other classes or parameters: it just gets the data and stores it in the DB.

So, on the surface, it's ok to use static methods for very simple examples.

Until the code logic grows, and static becomes "not cool anymore".


What if Static Method Needs CLASS PARAMETERS?

Now, let's imagine that the service PropertyService should be initialized with some parameter(s). For example, a city name which would later help in geolocation, in multiple methods of that class.

We would do that with the PHP 8 syntax of constructor property promotion.

app/Services/PropertyService.php:

class PropertyService {
 
public function __construct(public string $cityName)
{}
 
public static function store(array $propertyData): Property
{
// $this->cityName would be used in multiple methods
$propertyData['city'] = $this->cityName;
 
return Property::create($propertyData);
}
 
}

And then, we need to call our PropertyService from the Controller with passing the city name to it. But... how? We just have this:

PropertyService::store($propertyData);

We don't actually initialize the Service class properly.

The answer is: we can't do it. This is one drawback and limitation of using static methods: you can't pass the parameters to that class and use constructors for this.


What If Static Method Calls ANOTHER METHOD?

Now, let me show you how our class may become more complex, and then static wouldn't fit.

Imagine that PropertyService would have a separate private method to get the latitude/longitude coordinates by property address.

app/Services/PropertyService.php:

class PropertyService {
 
public static function store(array $propertyData): Property
{
$coordinates = $this->getCoordinates($propertyData['address']);
$propertyData['lat'] = $coordinates[0];
$propertyData['lon'] = $coordinates[1];
 
return Property::create($propertyData);
}
 
private function getCoordinates(string $address): array
{
// For simplicity, let's hardcode results, for now
return [0.0001, -0.0001];
}
 
}

While this code looks ok on the surface, you will get a PHP error:

Using $this when not in object context

You see, the static method doesn't know anything about the class it is in, so it doesn't know what $this is.

So, that's the first limitation: you can't (easily) call other methods from the same class from the static method.

Sure, there's a workaround to it: you can call self:: instead of $this->, and then it would work:

$coordinates = self::getCoordinates($propertyData['address']);

Another alternative is to make all the class methods static. Both are okay-ish, and I've seen them in real-life examples (a few of them at the end of the tutorial), but they are more like a workaround than a typical common practice.


What If Static Method Calls ANOTHER CLASS?

Let's expand our OOP structure even more and introduce a second Service class. It's a pretty good practice of "separation of concerns" to have a special class for coordinates only. Let's call it GeolocationService and put our method there.

app/Services/GeolocationService.php:

namespace App\Services;
 
class GeolocationService {
 
public function getCoordinatesByAddress(string $address): array
{
return [0.0001, -0.0001];
}
}

Now, how do we call it from our PropertyService?

class PropertyService {
 
public static function store(array $propertyData): Property
{
$coordinates = (new GeolocationService())->getCoordinatesByAddress($propertyData['address']);
$propertyData['lat'] = $coordinates[0];
$propertyData['lon'] = $coordinates[1];
 
return Property::create($propertyData);
}
}

It will actually work, no problem.

But what if you need that GeolocationService in multiple methods of PropertyService? It's inconvenient to call new GeolocationService() multiple times.

In Laravel, developers are used to injecting and type-hint classes into methods if it's auto-resolved by Laravel, but let's show the example with a constructor. So we would build the GeolocationService object in the constructor, with constructor property promotion, later to be used as $this->geolocationService->... in whichever method.

class PropertyService {
 
public function __construct(public GeolocationService $geolocationService)
{}
 
public static function store(array $propertyData): Property
{
$coordinates = $this->geolocationService->getCoordinatesByAddress($propertyData['address']);

Can you guess what error we would get?

Correct, the same: Using $this when not in object context

And, you would say we could use self:: again as a workaround?

self::geolocationService->getCoordinatesByAddress(...)

Not so fast. PHP would show this error: Undefined constant App\\Services\\PropertyService::geolocationService. So, the self:: workaround no longer works here.

So, we're bumping into the same problem: we can't (easily) use internal methods or external classes from static methods.


What if Static Method Needs To Be Tested with Mock?

When writing automated tests, it's a common practice to "fake" external service classes by mocking them.

It allows us to test the main functionality of the application without digging deeper into how exactly that external service works: we just assume its method works, passing certain parameters to it and expecting specific results.

I have a separate tutorial Laravel Testing: Mocking/Faking External 3rd Party APIs, but let's transform it to our example.

Let's say we have a Feature test to check if the property is saved successfully.

tests/Feature/PropertyTest.php

use App\Services\PropertyService;
 
class PropertyTest extends TestCase
{
use RefreshDatabase;
 
public function test_property_stored_successfully(): void
{
$address = '16-18 Argyll Street, London';
$response = $this->postJson('/api/properties', [
'address' => $address
]);
 
// This is the part I'm talking about
$this->mock(PropertyService::class)
->shouldReceive('store')
->with(['address' => $address])
->once();
 
$response->assertStatus(200);
}
}

As you can see, we're testing if our class's method store() is called once.

But, after running the php artisan test, we see this error:

FAILED Tests\Feature\PropertyTest > property stored successfully
InvalidCountException
Method store(['address' => '16-18 Argyll Street, London'])
from Mockery_2_App_Services_PropertyService
should be called exactly 1 times but called 0 times.

So, if you want to mock a static method in a class, it wouldn't work either.

In other words, globally speaking, static methods may be a problem when running automated tests.


So When We SHOULD Use Static Methods?

I don't know if you got the feeling from my examples above, but here's the summary.

You may use static methods if they are independent of other classes, their states, or other external variables.

In other words, you can look at static methods as global helpers, just living inside some class, for better grouping.

For example, Carbon::parse(), which we use for parsing the time, is a helper for the Carbon class.

Otherwise, if your method is part of an interconnected OOP structure with multiple classes, static methods should be used only very carefully and only temporarily before the refactoring in the future.

Sure, it may seem that your application is small and would never grow big, so it doesn't matter, but part of our jobs as developers is thinking about the future, to make the code maintainable for the team if the project skyrockets from a business point of view.


Open-Source Examples with Static Methods

Finally, we will look at three open-source examples where static methods are used appropriately, in my opinion.

Example 1. Monica: DateHelper

app/Helpers/DateHelper.php:

namespace App\Helpers;
 
use Carbon\Carbon;
 
class DateHelper
{
public static function parseDateTime($date, $timezone = null): ?Carbon
{
if (is_null($date)) {
return null;
}
if ($date instanceof Carbon) {
// ok
} elseif ($date instanceof \DateTimeInterface) {
$date = Carbon::instance($date);
} else {
try {
$date = Carbon::parse($date, $timezone);
} catch (\Exception $e) {
// Parse error
return null;
}
}
 
$appTimezone = config('app.timezone');
if ($date->timezone !== $appTimezone) {
$date->setTimezone($appTimezone);
}
 
return $date;
}
 
// ... many more methods around date/time
 
}

How is that method called elsewhere in the application? Here are a few examples.

database/factories/UserFactory.php:

$factory->define(App\Models\User\User::class, function (Faker\Generator $faker) {
return [
'first_name' => $faker->firstName,
// ...
 
'email_verified_at' => DateHelper::parseDateTime($faker->dateTimeThisCentury()),
];
});
 
// ...
$factory->define(App\Models\User\SyncToken::class, function (Faker\Generator $faker) {
return [
// ... other columns
 
'timestamp' => DateHelper::parseDateTime($faker->dateTimeThisCentury()),
];
});

tests/Feature/ContactTest.php:

public function test_user_can_be_reminded_about_an_event_once()
{
$reminder = [
'title' => $this->faker->sentence('5'),
'initial_date' => DateHelper::getDate(DateHelper::parseDateTime(
$this->faker->dateTimeBetween('now', '+2 years'))),
'frequency_type' => 'one_time',
'description' => $this->faker->sentence(),
];
 
// ...

As you can see, these static methods are just a helper class around dates, with almost no external dependencies except the Carbon class, which is a part of the Laravel application anyway.

So there's no point in making those helpers non-static because they don't save the "state" of any object.

Link to the repository with a full example: monicahq/monica


Example 2. BookStack: ApiToken Expiry

Here's an Eloquent model with one static method.

app/Api/ApiToken.php:

class ApiToken extends Model
{
// ...
 
public static function defaultExpiry(): string
{
return Carbon::now()->addYears(100)->format('Y-m-d');
}
}

How is that method called elsewhere in the application? Here are a few examples.

app/Api/UserApiTokenController.php:

class UserApiTokenController extends Controller
{
public function store(Request $request, int $userId)
{
// ...
 
$token = (new ApiToken())->forceFill([
'name' => $request->get('name'),
// ...
 
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
]);
}
}

database/seeders/DummyContentSeeder.php:

class DummyContentSeeder extends Seeder
{
public function run()
{
// ...
 
$token = (new ApiToken())->forceFill([
'user_id' => $editorUser->id,
'name' => 'Testing API key',
'expires_at' => ApiToken::defaultExpiry(),
'secret' => Hash::make('password'),
'token_id' => 'apitoken',
]);
}
}

This example shows a single static method in the Model which acts almost like a constant from the config, just expanded into a method with its own logic instead of being just a number or a string.

Link to the repository with a full example: BookStackApp/BookStack


Example 3. Akaunting: Str Utility

app/Utilities/Str.php:

use Illuminate\Support\Collection;
use Illuminate\Support\Str as IStr;
 
class Str
{
public static function getInitials($value, $length = 2)
{
$words = new Collection(explode(' ', $value));
 
// if name contains single word, use first N character
if ($words->count() === 1) {
$initial = static::getInitialFromOneWord($value, $words, $length);
} else {
$initial = static::getInitialFromMultipleWords($words, $length);
}
 
$initial = strtoupper($initial);
 
if (language()->direction() == 'rtl') {
$initial = collect(mb_str_split($initial))->reverse()->implode('');
}
 
return $initial;
}
 
public static function getInitialFromOneWord($value, $words, $length)
{
$initial = (string) $words->first();
 
if (strlen($value) >= $length) {
$initial = IStr::substr($value, 0, $length);
}
 
return $initial;
}
 
public static function getInitialFromMultipleWords($words, $length)
{
// otherwise, use initial char from each word
$initials = new Collection();
 
$words->each(function ($word) use ($initials) {
$initials->push(IStr::substr($word, 0, 1));
});
 
return static::selectInitialFromMultipleInitials($initials, $length);
}
 
public static function selectInitialFromMultipleInitials($initials, $length)
{
return $initials->slice(0, $length)->implode('');
}
}

The method is used in a few places as Eloquent Accessors:

‎app/Models/Common/Item.php‎:

public function getInitialsAttribute($value)
{
return Str::getInitials($this->name);
}

‎app/Models/Common/Contact.php‎:

public function getInitialsAttribute($value)
{
return Str::getInitials($this->name);
}

Again, this Str is a helper class for getting the initials of a person, which is created to avoid repeating the code in both of those Accessors above. Personally, I would maybe just call the class more clearly, like InitialsHelper instead of just Str.

The Str class has a static method, getInitials(), which uses other internal methods of the same class.

Then, all those methods are forced to be static, too: you can't call $this->getInitialFromOneWord() - as there's no $this in static, remember?

Link to the repository with a full example: akaunting/akaunting


So, that's pretty much all I can explain about using static methods in PHP/Laravel.

Do you agree with such an explanation? What would you add to make this explanation more precise and expressive?