# Laravel style guide

# About Laravel

First and foremost, Laravel provides the most value when you write things the way Laravel intended you to write. If there's a documented way to achieve something, follow it. Whenever you do something differently, make sure you have a justification for why you didn't follow the defaults.

# Configuration

Configuration filenames must use kebab-case.

config/
  pdf-generator.php

Configuration keys must use snake_case.

// config/pdf-generator.php
return [
    'chrome_path' => env('CHROME_PATH'),
];

Don't use the env helper outside of configuration files (why (opens new window)). Create a configuration value from the env variable like above.

# Artisan commands

The names given to artisan commands should all be kebab-cased.

# Yes
php artisan delete-old-records

# No
php artisan deleteOldRecords

A command should always give some descriptive feedback on what the result is, for both success and failure.

// Yes
class FooCommand extends Command
{
    public function handle(): void
    {
        // do some work

        $this->info('Old records have been deleted!');
    }
}
// No
class FooCommand extends Command
{
    public function handle(): void
    {
        // do some work

        $this->line('Operation successful!');
    }
}

# Routing

# URL naming

Public-facing URLs must use kebab-case.

https://hihaho.com/jobs/front-end-developer

# Route naming

Route names must use kebab-case. When applicable, use dot notation to describe the route structure.

// Yes
Route::get('video', 'VideoController@index')
    ->name('admin.video-container.index');

// No
Route::get('video', 'VideoController@index')
    ->name('video_container_admin_index');

# Slash in URL

A route URL should not start or end with / unless the URL would be an empty string.

// Yes
Route::get('/', 'HomeController@index');
Route::get('open-source', 'OpenSourceController@index');

// No
Route::get('', 'HomeController@index');
Route::get('/open-source', 'OpenSourceController@index');
Route::get('open-source/', 'OpenSourceController@index');
Route::get('/open-source/', 'OpenSourceController@index');

# Route method order

All routes have an HTTP verb, therefore we like to put the verb first when defining a route. It makes a group of routes very readable. Any other route options should come after it, on a new line and alphabetically ordered.

// Yes: HTTP verb comes first, subsequent calls on a new line, ordered alphabetically
Route::get('open-source', 'OpenSourceController@index')
    ->middleware('openSource')
    ->name('open-source');

// No: HTTP verbs not easily scannable
Route::name('home')->get('/', 'HomeController@index');

// No: subsequent calls are not ordered alphabetically
Route::get('OpenSourceController@index')
    ->name('open-source')
    ->middleware('openSource');

# Route groups

For route groups, use the available methods to define its options. The methods can be recognized by your IDE, thus making the declaration less sensitive to errors.

// Yes
Route::middleware('openSource')
    ->name('open-source')
    ->group(function (): void {
    
        // routes here
    });

// No: providing an array to define the options
Route::group([
    'middleware' => 'openSource',
    'name' => 'open-source',
], function (): void {

    // routes here
});

# Route parameters

Route parameters should use snake_case.

Route::get('news/{news_item}', 'NewsItemsController@index');

# Controllers

Controllers that control a resource must use the singular resource name.

class PostController
{
    // ...
}

Try to keep controllers simple and stick to the default CRUD keywords (index, create, store, show, edit, update, destroy). Extract a new controller if you need other actions.

In the following example, we could have PostController@favorite, and PostController@unfavorite, or we could extract it to a separate FavoritePostController.

class PostController
{
    public function create(): Response
    {
        // ...
    }

    // ...

    public function favorite(Post $post): Response
    {
        // do work
    }

    public function unfavorite(Post $post): Response
    {
        // do work
    }
}

Here we fall back to default CRUD words, store and destroy.

class FavoritePostController
{
    public function store(Post $post): Response
    {
        $post->favorites()
            ->create([
                'user_id' => auth()->id(),
            ]);

        // ...
    }

    public function destroy(Post $post): Response
    {
        $post->favorites()
            ->where('user_id', auth()->id())
            ->delete();

        // ...
    }
}

This is a loose guideline that doesn't need to be enforced.

# Views

View files and their directories must use kebab-case.

resources/
    views/
        video-container/
            video-template.blade.php
class VideoContainerController
{
    public function index(): View
    {
        return view('video-container.video-template');
    }
}

# Validation

# Custom validation rules

Prefer custom validation rule classes implementing Illuminate\Contracts\Validation\Rule over using Validator::extend().

A class usage can easily be clicked-through, showing the validation logic and message in one place.

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Validator;

// Yes
class FontAwesomeRule implements Rule
{
    public function message(): string
    {
        return __('validation.en.' . self::class);
    
        // or
        return 'The :attribute must be a valid Font Awesome icon.';
    }

    /**
     * @param string $attribute
     */
    public function passes($attribute, $value): bool
    {
        return app('font-awesome')->has($value);
    }
}

Validator::make($request->all(), [
    'foo' => new FontAwesomeRule(),
]);


// No
Validator::extend('fa', function ($attribute, $value): bool {
    return app('font-awesome')->has($value);
});

Validator::make($request->all(), [
    'foo' => 'fa',
]);

# Blade Templates

Indent using four spaces.

<a href="/open-source">
    Open Source
</a>

Don't add spaces after control structures.

@if($condition)
    Something
@endif

# Authorization

Policy abilities must be written in camelCase and have an associated class constant.

Try to name abilities using the following CRUD words:

  • Use viewAny (not index).
  • Use view (not show)
  • Use create (not store)
  • Use update (not edit)
  • Use delete (not destroy)
class VideoPolicy extends Policy
{
    const VIEW = 'view';

    public function view(User $user, Video $video): bool
    {
        return $user->id == $video->user_id;
    }
}

One should be able to read the ability from a user context. E.g. a server shows a resource, a user views it:

// Yes
$user->can(VideoPolicy::VIEW, $video);

// No
$user->can(VideoPolicy::SHOW, $video);

Use the policy ability constant in every context:

@can(HiHaHo\Policies\PostPolicy::UPDATE, $video)
    <a href="{{ route('video.edit', $video) }}">
        Edit
    </a>
@endcan
The Laravel Way

This guideline is in accordance with the Laravel policy ability naming, as can be found in the default Laravel policy stub (opens new window). This is compatible with e.g. Laravel Nova.

# Translations

Translation keys must be:

  • Written snake_case
  • Using . for namespacing
echo __('video_copy_helper.duplicate_video.display_name_prefix');

# Naming Classes

Naming things is often seen as one of the harder things in programming. That's why we've established some high level guidelines for naming classes.

# Controllers

Controllers are named by the singular form of their corresponding resource with a Controller suffix.

E.g. VideoController

When writing non-resourceful controllers you might come across invokable controllers that perform a single action. These can be named by the action they perform again suffixed by Controller.

E.g. PerformCleanupController

# Resources

Eloquent resources are named by the singular form of their corresponding resource with a Resource suffix.

E.g. VideoResource

# Jobs

A job's name should describe its action.

E.g. CreateUser or PerformDatabaseCleanup

# Events

Events will often be fired before or after the actual event. This should be very clear by the tense used in their name.

E.g. ApprovingLoan before the action is completed and LoanApproved after the action is completed.

# Listeners

Listeners will perform an action based on an incoming event. Their name should reflect that action with a Listener suffix. This might seem strange at first but will avoid naming collisions with jobs.

E.g. SendInvitationMailListener

# Commands

Suffix commands with Command. This avoids naming collisions with jobs.

e.g. PublishScheduledPostsCommand

# Mailables

Suffix mailables with Mail, as they're often used to convey an event, action or question.

e.g. AccountActivatedMail or NewEventMail

# Notifications

Suffix notifications with Notification.

e.g. ResetPasswordNotification

# Query builders

# Aliasing

The base and Eloquent query builders should be imported with an alias, to clarify their purpose.

To keep a clear distinct with the base query builder, the Eloquent builder should always be aliased EloquentQueryBuilder, even when there is no base builder imported.

// Yes

use Illuminate\Database\Eloquent\Builder as EloquentQueryBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Schema\Builder as SchemaBuilder;

// No

use Illuminate\Database\Query\Builder;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;

# Chaining

Order the chained query builder method calls by using the correct MySQL syntax order, prepended by non-SQL operations like with or without.

After the first call, every chained method should be placed on a new line.

// Yes
$user->containers()
    ->with(['subscription', 'subContainersWithSubscription'])
    ->select('id')
    ->where('foo', '!=', 'bar')
    ->groupBy('id')
    ->get();

// No
$user->containers()->select('id')
    ->groupBy('id')->where('foo', '!=', 'bar')
    ->with(['subscription', 'relation'])
    ->get();

When opening a query from static model context, use the query() method for clarification and consequent alignment of subsequent builder method calls. This does not apply to single static calls like ::first() or ::all().

// Yes
User::query()
    ->with(['subscription', 'subContainersWithSubscription'])
    ->get();

// Yes
User::findOrFail($id);

// No
User::with(['subscription', 'subContainersWithSubscription'])
    ->get();

# Eloquent

Eloquent is the ORM used within hihaho, below there are some stipulations on how to use it.

# Accessors and mutators

In general accessors and mutators (opens new window) should be avoided. Typically, you'll find you can use one of the following methods instead:

# Casts

# Custom casts

When the logic of the accessor and/or mutator is not specific to the model or its attribute, you can use custom casts (opens new window) using the Illuminate\Contracts\Database\Eloquent\CastsAttributes interface.

An example cast class
final class CurrencyCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes): ?Currency
    {
        if (is_null($value)) {
            return null;
        }

        return Money::parseCurrency($value);
    }

    /**
     * @param Model $model
     */
    public function set($model, string $key, $value, array $attributes): ?string
    {
        if (is_null($value)) {
            return $value;
        }

        if ($value instanceof Currency) {
            return $value->getCode();
        }

        return (string) $value;
    }
}

# Accessor alternatives

  • Plain getter methods without the get... prefix and ...Attribute suffix. This to indicate the returned value might be a modified database value, or not associated with a single underlying database value at all. Apart from the clarity that comes with a direct click-through, calling a getter method instead of an attribute also explains to the developer the retrieved value is being computed, which in some cases might be an expensive operation.
An example of a plain getter method
public function singleUserContainer(): VideoContainer
{
    return $this->containers->first() ?? new VideoContainer([
            VideoContainer::TRIAL_ENDS_AT => now()->addDays(14),
        ]);
}

# Mutator alternatives

Before any attribute values are passed to these alternatives, they need to be validated and sanitized.

  • Plain setter methods without the ...Attribute suffix.
  • Action classes that set one or more model attribute directly with the value that should be persisted.

If you are nevertheless convinced an accessor and/or mutator should be added, the classic convention should be used. This means the method should be named using the get... / set... prefix and ...Attribute suffix, without returning an Illuminate\Database\Eloquent\Casts\Attribute instance.

An example of attribute methods
// Yes
protected function getFirstNameAttribute(): string
{
    return ucfirst($this->name);
}

// No
protected function firstName(): Attribute
{
    return Attribute::make(
        get: fn ($value) => ucfirst($value),
    );
}

# Pivot tables

Pivot table names are not bound to the Laravel convention.

# Scopes and custom Eloquent builder

Using a model dedicated custom query builder class is preferred over maintaining scopes within the model itself.

Dedicated query builder motivation

Apart from the separation of concerns, this will allow for type hinting and therefore help the IDE IntelliSense as well. Using a custom query builder class will also reduce some boilerplate like the scope...() prefix and the $query argument.

The following convention is being used to prefix the query builder scopes:

  • for...() to indicate a filtering by relation
  • search...() to indicate a query filtering using search terms
  • which...() to indicate other constraints that translate into WHERE database queries, e.g. whichIs...() or whichHas...().
Motivation for using the which...() prefix

Prefixing with where...() might be considered confusing for the developer because of the 'magic' Laravel uses on this when the method would not exist. This would mean e.g. whereIsAbandened() could be read as where('is_abandoned', ...). Meaning a developer writing the query has to think about whether we have an is_abandoned attribute on the model, or it's an actual existing scope method.

Apply singular naming unless the method expects or supports plural input.

// Yes

use Illuminate\Database\Eloquent\Builder as EloquentQueryBuilder;

/**
 * @property UploadVideo $model
 */
final class UploadVideoQueryBuilder extends EloquentQueryBuilder
{
    /**
     * @param int[] $containerIds
     *
     * @return $this
     */
    public function forContainers(iterable $containerIds): self
    {
        return $this->whereIn('container_id', $containerIds);
    }

    /**
     * @return $this
     */
    public function searchByName(?string $search): self
    {
        return $this->when($search, function (self $query) use ($search): void {
            $query->where('video_name', 'SOUNDS LIKE', '%' . $search . '%')
                ->orWhere('video_name', 'LIKE', '%' . $search . '%');
        });
    }

    /**
     * @return $this
     */
    public function whichIsFinished(): self
    {
        return $this->whereIn('status', UploadVideoStatus::getCompleteStates());
    }
}
// No

final class UploadVideoQueryBuilder extends EloquentQueryBuilder
{
    /**
     * The method should be named plural as it supports multiple container IDs.
     *
     * @param int[] $containerIds
     *
     * @return $this
     */
    public function forContainer(iterable $containerIds): self
    {
        //
    }

    /**
     * The method should be named singular per convention.
     *
     * @return $this
     */
    public function whichAreFinished(): self
    {
        //
    }
}