Back to Course |
Larastan: Catch Bugs with Static Analysis

Example 4: Null Class in Relationships

Once we switch to higher levels of Larastan - we'll start to see more errors that are stricter and could be a great help. Let's set up the example:

app/Services/ClientReportsService.php

<?php
 
namespace App\Services;
 
use App\Models\Transaction;
use Carbon\Carbon;
 
class ClientReportsService
{
public function getReport($request)
{
$query = Transaction::with('project')
->with('transaction_type')
->with('income_source')
->with('currency')
->orderBy('transaction_date', 'desc');
 
if ($request->has('project')) {
$query->where('project_id', $request->input('project'));
}
 
$transactions = $query->get();
 
$entries = [];
foreach ($transactions as $row) {
$date = Carbon::createFromFormat(config('panel.date_format'), $row->transaction_date)->format('Y-m');
if (!isset($entries[$date])) {
$entries[$date] = [];
}
$currency = $row->currency->code;
if (!isset($entries[$date][$currency])) {
$entries[$date][$currency] = [
'income' => 0,
'expenses' => 0,
'fees' => 0,
'total' => 0,
];
}
$income = 0;
$expenses = 0;
$fees = 0;
if ($row->amount > 0) {
$income += $row->amount;
} else {
$expenses += $row->amount;
}
if (!is_null($row->income_source->fee_percent)) {
$fees = $row->amount * ($row->income_source->fee_percent / 100);
}
 
$total = $income + $expenses - $fees;
$entries[$date][$currency]['income'] += $income;
$entries[$date][$currency]['expenses'] += $expenses;
$entries[$date][$currency]['fees'] += $fees;
$entries[$date][$currency]['total'] += $total;
}
 
return $entries;
}
}

For this we'll expand our code example with our Relationship definitions to better see the whole picture:

app/Models/Transaction.php

// ...
public function income_source(): BelongsTo
{
return $this->belongsTo(IncomeSource::class, 'income_source_id');
}
 
public function currency(): BelongsTo
{
return $this->belongsTo(Currency::class, 'currency_id');
}

So let's see what Larastan will show us here!

For this example, we'll be at level 8.

phpstan.neon

includes:
- ./vendor/nunomaduro/larastan/extension.neon
 
parameters:
paths:
- app/
 
# Level 9 is the highest level
level: 8
------ ----------------------------------------------------------------------------------------------------------
Line ClientReportsService.php
------ ----------------------------------------------------------------------------------------------------------
31 Parameter #2 $time of static method Carbon\Carbon::createFromFormat() expects string, string|null given.
39 Cannot access property $code on App\Models\Currency|null.
56 Cannot access property $fee_percent on App\Models\IncomeSource|null.
57 Cannot access property $fee_percent on App\Models\IncomeSource|null.
------ ----------------------------------------------------------------------------------------------------------

Quickly we can see a few issues reported already. Keep in mind that the code passed checks on level 6 but as levels add more checks - we'll get more errors.

  1. We are not sure if we will always get a string for our $time variable. It might be null and that would break our code.
  2. We are not sure if our $row->currency will always be set. The related Model might have been Soft Deleted.
  3. We are not sure if our $row->income_source will always be set. The related Model might have been Soft Deleted.

Let's protect our code from these issues!


Fixing our $time Variable

How can this be? Well, we have our $time variable as nullable in the database, and looking in the code we can find that our Model has an accessor for this field:

app/Models/Transaction.php

// ...
 
public function getTransactionDateAttribute($value): ?string
{
return $value ? Carbon::parse($value)->format(config('panel.date_format')) : null;
}
 
public function setTransactionDateAttribute($value): void
{
$this->attributes['transaction_date'] = $value ? Carbon::createFromFormat(config('panel.date_format'), $value)->format('Y-m-d') : null;
}

This means that sometimes it can actually return null which would break our flow. How do we fix that? We can of course add a condition that would check if it's null to skip that row from being included:

app/Services/ClientReportsService.php

// ...
if (!$row->transaction_date) {
continue;
}
 
$date = Carbon::createFromFormat(config('panel.date_format'), $row->transaction_date);
 
// ...

With this solution, we will simply skip the row if it doesn't have a date set. This might change our reports, but it's better than having an error.

If we run ./vendor/bin/phpstan analyse again we'll see that the error is gone:

------ ----------------------------------------------------------------------
Line ClientReportsService.php
------ ----------------------------------------------------------------------
43 Cannot access property $code on App\Models\Currency|null.
60 Cannot access property $fee_percent on App\Models\IncomeSource|null.
61 Cannot access property $fee_percent on App\Models\IncomeSource|null.
------ ----------------------------------------------------------------------

Fixing our Currency and Income Source Relationships

As with many relationships - we have to be sure that the relationship was loaded correctly. It may not seem that important but with the usage of Soft Delete - you can have some resources that have an ID but the actual Model can't be loaded.

In this case, we have two possible cases - Currency and IncomeSource not being loaded when we are trying to access the data. Here's how we can fix them:

To fix an issue with our Currency we'll set a default value for it:

app/Services/ClientReportsService

// ...
 
// Locating line #43
$currency = $row->currency->code ?? 'USD';
 
// ...

By adding ?? 'USD' we've told the code to use USD as our default currency if there's no code available.

Running ./vendor/bin/phpstan analyse will not show an error anymore:

------ ----------------------------------------------------------------------
Line ClientReportsService.php
------ ----------------------------------------------------------------------
60 Cannot access property $fee_percent on App\Models\IncomeSource|null.
61 Cannot access property $fee_percent on App\Models\IncomeSource|null.
------ ----------------------------------------------------------------------

As for our IncomeSource, we can see in the code that it's completely optional and may not always be present. So we can change our code a little to add a check if it's not null:

app/Services/ClientReportsService

// ...
 
// Locating line #60
if ($row->income_source && !is_null($row->income_source->fee_percent)) {
$fees = $row->amount * ($row->income_source->fee_percent / 100);
}
 
// ...

By adding the $row->income_source && to our condition we've told the code that ignores the fee if it is null.

Running ./vendor/bin/phpstan analyse will now completely pass our checks:

[OK] No errors

It is indeed that simple, but also it may not reflect our business needs for both of the changes. Sometimes it might be better to prevent the deletion of Relationships by adding checks