# PHP Doc Blocks

Whether you’re a Laravel software developer or any other type of PHP software developer you’ve no doubt written docblocks in your code at some point. Docblocks are extremely helpful at providing insight into your application for other developers and for static analysis tools to understand the context and provide suggestions and tooltip help windows.

For this purpose, docblocks are brilliant! They bridge the gap between the software developer and the codebase and with the correct tools they provide an enhanced level of programming.

But at what point should you stop docblocking?

Sometimes these code annotations become more of a nuisance than they help, and before you know it, you’ve added new arguments to a method, changed argument names, and suddenly you’ve found yourself with outdated and incorrect doc blocks. Whoops.

These are bags of corn if you couldn’t tell.

Let’s take a look at this rudimentary example of a library that utilises docblocks:

<?php

class Csv
{
    /**
     * @var int Number of rows
     */
    protected $rowCount;

    /**
     * Get the number of rows
     * 
     * @return int
     */
    public function rowCount()
    {
        return $this->rowCount;
    }
}

$count = $csv->rowCount(); // We know this *should* be an integer.

if ($count === null) {
    // We know this *should* never happen
}

IDEs with static analysis features can interpret this code and know that the $count variable can only ever be an int and as such may even provide feedback for this code to highlight the fact that we have an if condition that will never evaluate true and as such should be rectified or removed. It can do this by scanning the codebase and docblocks to understand the types of variables, properties, and return types for functions.

As PHP has evolved, we’ve been leaning towards a type-strict programming language. This allows the developers to have a better understanding of the workflow and possible code flows, as this not only provides IDEs with the same level of information but it also enforces it by throwing a TypeError whenever a type violation is encountered as opposed to just allowing a type violation to occur.

As docblocks are so ingrained in a lot of us, in most cases you will find yourself doing both docblocks and return types — what has almost seemed to be an industry standard these days:

<?php

class Csv
{
    /**
     * @var int Number of rows
     */
    protected $rowCount; // ideally this should be type hinted too

    /**
     * Get the number of rows.
     * 
     * @return int
     */
    public function rowCount(): int
    {
        return $this->rowCount;
    }
}

$count = $csv->rowCount(); // We know this *will* be an integer, otherwise a TypeError is thrown

if ($count === null) {
    // We know this *will* never happen, so this condition can be removed
}

We’re type hinting more and more things: return types for functions and methods, types of parameters/arguments, and as of PHP 7.4.0 we’ve begun type hinting class properties. PHP is evolving from requiring docblocks to describe types of variables to strictly type-hinting these variables instead — at least where possible, and frankly, I’m loving it. Please sir, may I have some more strict types?

So where does it leave our beloved docblocks?

In general, docblocks are still relevant in some cases, especially when it comes to describing your code. They also provide a more granular level of type hinting for complex variables (such as PHP generics — more on this later).

But when it comes to typehinting a parameter, return value, or class property that has already been type hinted, you’re not only wasting your time, you’re writing junk (sorry if that stings you) that often gets neglected and forgotten about, which may end up wasting other developers’ time when they are mislead by the actual arguments to a function.

As your application evolves, you often find yourself in a situation where your code looks like this:

<?php

class Csv
{
    /**
     * @var string CSV file path
     */
    protected $file;

    /**
     * Create new CSV reader
     * 
     * @param array $rows                      // whoops we now use a file path and read the CSV internally
     * @param bool $anArgumentYouUsedToSupport // whoops this isn't supported anymore
     * @return void
     */
    public function __construct(string $file)
    {
        $this->file = $file;

        $this->boot();
    }
}

or worse yet, a simple missing backlash can result in obscure results:

<?php

use Carbon\Carbon;

class MyClass
{

    /**
     * @param Carbon\Carbon $after           // a missing backslash means Carbon\Carbon is relative to Carbon\Carbon, therefore Carbon\Carbon\Carbon
     * @return Carbon\Carbon
     */
    public function getNextDate($after)
    {
        return Carbon::now();
    }
}

$object->getNextDate(now()); // Expected Carbon\Carbon\Carbon, Carbon\Carbon provided. 

Thankfully your IDE (probably) knows your docblocks are not to be trusted, as the source of truth is the parameter signature in the code itself, but it can often end up suggesting both: “We don’t know what to expect so you get both suggestions in case the docblocks are actually correct”.

So really, if we’re typehinting variables, return types and properties, then don’t bother with adding docblocks to explain the type of variables you are interacting with. You’re double handling, so why not clean up your code a bit?

<?php

class Csv
{
    /**
     * Rows in the CSV
     */                                                  // Remove @var declaration
    protected string $rows;                              // Move type from docblock to property definition

    /**
     * Create new CSV reader
     */                                                  // Remove @param and @return declarations.
    public function __construct(string $file)
    {
        $this->file = $file;                             

        $this->boot();
    }
}

A lot cleaner already!! Then I would ask you to look at your remaining docblock and ask yourself:

  • Are the comments which I have written helpful?
  • Is it imperative that the developer reads this message due to the ambiguity of the method or property?

Chances are the answers will be: No and No.

A getUserFullNameAttribute method does not need a comment saying it “gets the user’s full name”. Arguably never has, definitely never will.

So while you’re there removing the redundant @return, @param and @var dockblocks, don’t hesitate to also remove redundant comments as well.

<?php

class Csv
{
    protected string $file;

    public function __construct(string $file)
    {
        $this->file = $file;

        $this->boot();
    }
}

Of course in this example with modern PHP versions you’d most likely simplify it again

<?php

class Csv
{
    public function __construct(protected string $file)
    {
        $this->boot();
    }
}

Now we’re talking! Everything you need to know can be interpreted very easily from this minimal code alone.

The body of this example CSV class went from 18 lines to 4 lines, and provides the same level of context: “The CSV class constructor accepts a single argument – $file – which is a string and based on the name of this variable it’d be 99.9% obvious that the CSV file is the file to be read, which is also a protected property of the same name.”

I find this much, much cleaner. And it’s not just me either, this is not a fresh concept to the PHP community, in fact, it’s been growing in following over the years especially as PHP expands its typehinting capabilities (addition of unions, property types, etc). More and more top PHP developers and frameworks including Spatie and Laravel 10 are dropping redundant docblocks where they serve no purpose.

As of yet there’s no standardised name for this type of approach, but that’s not surprising as docblock standards were never really fleshed out properly in PSR-1, PSR-2 or PSR-12. It’s been kind of neglected.

Compare the old approach vs the clean approach

So let’s take a full look at a semi-realistic Laravel model, with and without redundant docblocks:

<?php

namespace App\Models;

use App\Enums\State;
use App\Jobs\ResyncLocationWithExternalServices;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Http\Request;

/**
 * A organisation's location
 */
class Location extends Model
{
    /**
     * Pretty model name, so pretty
     *
     * @var string
     */
    public const MODEL_LABEL = 'Location';

    /**
     * The attributes that aren't mass assignable.
     *
     * @var array
     */
    protected $guarded = [];

    /**
     * The attributes that should be cast.
     *
     * @var array
     */
    protected $casts = [
        'requires_appointment' => 'boolean',
    ];

    /**
     * The accessors to append to the model's array form.
     *
     * @var array
     */
    protected $appends = [
        'address',
        'name',
    ];

    /**
     * A location will have many users
     *
     * @return HasMany
     */
    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }

    /**
     * A location will have a single manager
     *
     * @return HasOne
     */
    public function manager(): HasOne
    {
        return $this->hasOne(User::class)->role('manager');
    }

    /**
     * A location will belong to an organisation
     *
     * @return BelongsTo
     */
    public function organisation(): BelongsTo
    {
        return $this->belongsTo(Organisation::class);
    }

    /**
     * Scope the results to those locations that allow walkins
     *
     * @param Builder $query
     * @return Builder
     */
    public function scopeAllowsWalkIn(Builder $query): Builder
    {
        return $query->where('required_appointment', false);
    }

    /**
     * Scope the results to those locations that are in the given state
     *
     * @param Builder $query
     * @param State $state
     * @return Builder
     */
    public function scopeInState(Builder $query, State $state): Builder
    {
        return $query->where('state', $state->value);
    }

    /**
     * Get the address formatted in a single line
     *
     * @return Attribute
     */
    public function address(): Attribute
    {
        return new Attribute(
            get: fn (): string => implode(', ', [
                $this->street,
                $this->city,
                $this->state . ' ' . $this->postcode,
            ]),
        );
    }

    /**
     * Get the name of the location (e.g. 'Australia Post Subiaco')
     *
     * @return string
     */
    public function getNameAttribute(): string
    {
        return $this->organisation->name . ' ' . $this->city;
    }

    /**
     * Resync this job with all external services
     *
     * @return void
     */
    public function queueResync(): void
    {
        ResyncLocationWithExternalServices::dispatch();
    }

    /**
     * Update the details of this location with the given request.
     * Optionally, resync with external services.
     *
     * @param Request $request
     * @param bool $resyncWithExternalServices
     * @return void
     */
    public function updateDetails(Request $request, bool $resyncWithExternalServices = true): void
    {
        $this->update($request->validated());

        if ($resyncWithExternalServices) {
            $this->queueResync();
        }
    }
}

(yikes) versus:

<?php

namespace App\Models;

use App\Enums\State;
use App\Jobs\ResyncLocationWithExternalServices;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Http\Request;

class Location extends Model
{
    public const MODEL_LABEL = 'Location';

    protected $guarded = [];

    protected $casts = [
        'requires_appointment' => 'boolean',
    ];

    protected $appends = [
        'address',
        'name',
    ];

    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }

    public function manager(): HasOne
    {
        return $this->hasOne(User::class)->role('manager');
    }

    public function organisation(): BelongsTo
    {
        return $this->belongsTo(Organisation::class);
    }

    public function scopeAllowsWalkIn(Builder $query): Builder
    {
        return $query->where('required_appointment', false);
    }

    public function scopeInState(Builder $query, State $state): Builder
    {
        return $query->where('state', $state->value);
    }

    public function address(): Attribute
    {
        return new Attribute(
            get: fn (): string => implode(', ', [
                $this->street,
                $this->city,
                $this->state . ' ' . $this->postcode,
            ]),
        );
    }

    public function getNameAttribute(): string
    {
        return $this->organisation->name . ' ' . $this->city;
    }

    /**
     * Resync this job with all external services including Xero and Google
     */
    public function queueResync(): void
    {
        ResyncLocationWithExternalServices::dispatch();
    }

    public function updateDetails(Request $request, bool $resyncWithExternalServices = true): void
    {
        $this->update($request->validated());

        if ($resyncWithExternalServices) {
            $this->queueResync();
        }
    }
}

Now we’re talking!

“But you just stripped out all docblocks except one…?” Well, yes. The class, the constant, all properties and methods here were self explanatory, except what `queueResync` does.

The beauty of this is that not only can you see a lot more code on the screen at any one time, the functions are also clear to easy to identify visually, and when you refactor code you’re not having to refactor the docblocks (as much).

We’ve all seen it before, and we’ve probably all done it before. When refactoring, it’s easy to miss a comment that references the old way of doing things, that either doesn’t apply to the refactored code or worse yet is misleading.

Reasons to keep Docblocking

Despite all this said, as mentioned above, there are still many use cases for docblocks. So not all is lost. You might need to explain the following scenarios to other developers.

@throws

/**
 * @throws ValidationException when validation fails
 */
public function validate(): void
{
    // do validation
}

This will tell the user that when they run $object->validate() that they shouldn’t expect a response (void return type) and on validation failure they should expect a ValidationException to be thrown.

@param, @var/@property, @return | in the context of PHP Generics

    /**
     * @var array<string,string> $headers as snake_case => Title Case
     */
    protected array $headers = [];

    /**
     * @return array<string,CsvCell> as header_snake_case => CsvCell instance
     */
    public function getNextRow(): array
    {
        // get next row    
    }

If you’re not familiar with array<int,string> or iterable<string,ClassName> or other similar types, a quick crash course for you on a<b,c> is:

a: The type of variable to expect (e.g. it’s an array or Collection or something else)

b: The type of each key when iterated (e.g. it’s keyed by a string value, so string)

c: The type of each value when iterated (e.g. it’s an array of User models, so User)

In other words for the most part: foreach ($a as $b => $c) { /* */ }

Because PHP doesn’t support generics typehinting yet, this is a very valid use case of @var, @param and @return (even @property). Just, please don’t forget to update them when you refactor or copy/paste the code!

@var (inline)

    $product = Product::factory()->create(); // mixed
    $product->categories();                  // unknown

    // vs

    /** @var Product $product */
    $product = Product::factory()->create(); // \App\Models\Product
    $product->categories();                  // \Illuminate\Database\Eloquent\Relations\BelongsToMany

Especially in Laravel (without sufficient tools to assist your IDE), you’ll need to explain what a variable is from time to time if you want the auto-completion and instant type feedback. This is where @var works great. Containers, like app() return a mixed result, but in cases where you provide it an MyInterface, only a class with that MyInterface can be returned, and this is where @var can be quite helpful (provided your IDE doesn’t provide this intelligence)

@see, @author, etc

These are fine to use in moderation.

Comments in general

As described above, your method or property may be ambiguous. While I’d first recommend renaming the method or property to something explicit (e.g. from public function age(): int to public function ageInYears(): int), sometimes you may be unable to explain the complex code in the name, such as algorithms or data manipulation. This is a prime example of where commenting is not only acceptable but also expected.

But please, do not write junk comments for the hell of it, and for everyone’s sanity, please don’t do something like this:

/**
 * This function will provide you the ability to pass in a State enum
 * to this Location which will be evaluated to then determine when
 * this Location will be open today, if there exists a location
 * which resides in the given State otherwise you get `null`
 * Look at me, I reduce each line by ~3 chars every line.
**/
public static function openingHoursInState(State $state): ?OpeningHours
{
    // logic
}

This trend of writing docblocks that decrease each line by ~3 characters is silly especially when you can write short, succinct comments and get straight to the point. For example: “Get opening hours for today in the given state“.

Should you, or should you not?

Yes, write code that helps your peers, explains complex issues, add generics, and reference GitHub issues or mention exceptions that may be thrown. But don’t double up your code with typehints and docs with return/param types, and keep your comments to a minimum where possible, for the sanity of everyone who has to look at your code in future.