Skip to content

Code

Request handlers

  • Auto-resolved and auto-invokable controller
  • One handler per request

One exception: resource controllers for API endpoints

The before method

Optional

  • Handles permissions, custom authorization, enforcers, interactions, etc.
  • Added in addition to the constructor so all middleware and request values are resolved
  • Allows dependency injection using type-hinted variables

For a resource, use a combination of policy authorization and existence check (using a custom macro):

php
public function before(ApiClient $client): void
{
    $this->authorize(Abilities::UPDATE_IN_STORE_BACKOFFICE, [$client, store()]);

    store()
        ->api_clients()
        ->presentOrFail($client->id);
}

The handle method

  • Handles a request
  • Returns a response

Enforcers

Somewhat custom validators that return a simple boolean to determine if the request should continue or not.

Interactions

More enhanced validators that interact with a request and can even perform certain actions on their own. If an interaction deems a request unfilfillable, they can "hijack" the request and return an early response.

Form requests

Form requests match user input against a set of rules and return readable errors if they fail. If they pass, valid input can be retrieved and safely used for further processing.

They can also prepare input and other data for the controller using public methods.

Usually type-hinted in the handle method so they are automatically resolved and validate on their own.

Translations

The trans() and @trans() helpers are Laravel's default helpers and should be used to translate strings that do not contain user input.

The t() and @t() helpers were added to efficiently translate strings that do contain user input in the $replace parameter and should be escaped before being used in the translation string. There is currently no custom t_choice() helper, but feel free to add it.

On the front-end in Vue, we have the global, reactive $t() and $tChoice() helpers which use static trans and transChoice from the laravel-vue-i18n npm package.

Assets

The PHP and Blade asset() helpers provide access to static files in the /public directory. Use this instead of directly referencing the file so it works on all environments we deploy to. For Vue components, use the direct relative path to the asset with a leading forward slash (to start from the root URL).

Preventing XSS attacks

Using Blade and Vue together unwantingly enables XSS attacks. The reason behind this is that a Blade variable can contain the curly braces () that Vue uses to echo variables. If we output that in Blade without escaping it, it could crash the app or even execute code.

To prevent this, we need to escape all variables echo'd by Blade using v-pre:

html
<h3 v-pre>{{ $featuredArticle->title }}</h3>

Doing so renders any double curly braces in Blade variables harmless and we mitigate the attack vector.

Note: you should be dilligent, but selective when using v-pre, as it will disable rendering of any Vue component in the HTML tag it's applied to. So you can't and shouldn't apply it to the most parent tag or layout body.

When to use form elements

The following components are made specifically for use in backoffice <form> elements and some with Inertia forms.

FormClearInput.vue
FormDateRangePicker.vue
FormEmailInput.vue
FormFieldError.vue
FormLabel.vue
FormNumberInput.vue
InertiaForm.vue
InertiaFormSubmitButton.vue

The styles in backoffice-forms.css can be used with a variety of other input elements, even outside forms.

Activity log

When changing the implementation of logging an activity, be sure to update the ActivityFactory and other classes, front-end components, etc to match the implementation. It's very important these match 1:1 in terms of properties, parameters, and translation parameters as it could otherwise break front-end pages.

Horizon queues

Queued jobs, notifications, and event listeners are handled by Laravel Horizon.

At the root, we have 3 queues: high, default, and low. They should be used in code by their enum, e.g. \App\Enums\Queue::High->value. A job or another queued class dispatched without specifying a queue is added to the default queue.

The high queue should be used for important jobs that need to be processed instantly, without blocking the user's HTTP request. Think media, reports, and important medical notifications.

The default queue is for everyday tasks. They contain the grunt of the jobs and are processed in seconds. Add a job here (by not defining the queue) if you don't know where to put it or it doesn't matter how long it takes.

The low queue is meant for often long-running, non-priority, batch jobs that take a while to process. E.g. scheduled cron jobs like average scores, automated suggestions, and score calculation that consist of thousands of jobs and usually block other jobs in the queue.

Note: if you have a job that takes a while to complete (< 5 minutes), but is very important, it should be dispatched on the high queue (or default if important-ish).

Configuration

Horizon is configured with 3 queues and thus 3 supervisors. Each supervisor consists of 1 or more workers and each worker can process the jobs of a single queue. If a long-running job is blocking the queue somehow, it'll automatically scale up the supervisor from 1 to X configured workers to process the other jobs on the same queue.

Note: the typical configuration consists of multiple queues on a single supervisor, but that doesn't allow proper configuration and scaling, and allows a single job to hog all resources effectively blocking all other queues. Our current setup works around this by isolating the queues per supervisor. A supervisor daemon does not share resources with other supervisors and works as a standalone process (with multiple worker sub processes).

Important to note is that the timeout is set to 300 seconds or 5 minutes. This needs to match or be lower than the value of connections.redis.retry_after in config/queue.php.

Media

To add media to an Eloquent model, implement the \Spatie\MediaLibrary\HasMedia interface and add the \Domain\Media\Models\HandlesMedia trait.

Then, add one of the existing classes for the type of media you want it to handle, or write your own:

  • \Domain\Media\Models\HasACover
  • \Domain\Media\Models\HasALogo
  • \Domain\Media\Models\HasAProfilePicture
  • \Domain\Media\Models\HasImages

By default, all original media is preserved and has their origin file name changed to a random 40 character string. The media model has been extended to be sortable, use fake IDs, and handle dates.

The \Domain\Media\Models\CanLoadMedia trait included in HandlesMedia also provides some convenience methods to load media collections. Be sure to use those before any other method to pick the right collections, otherwise it will load all collections and all media (even when you only want one).

Responsive images caveat

The responsive images generated with Spatie's media library are great for displaying large, full-size images in high quality on different device screens, but these are not suitable for use in smaller HTML containers (think avatars and cards) that are not the full viewport's width.

They always render e.g. 2000x2000px images in a 40x40px div because the img srcset uses the image best suited for the viewport width, not the container width. To handle this, we need to purposefully generate an extra image for the desired dimensions and use that instead.

We do so by registering one (or more) media conversions like \Domain\Media\Conversions\SmallMediaConversion. Because we probably should not crop images when we generate them to not lose any data, we need to make it fit the given boundaries without increasing the image's dimensions; i.e. Manipulations::FIT_MAX.

New conversions are only generated for new images (not existing media), so whenever you add a new conversion you need to execute Artisan::call('media-library:regenerate', ['--only-missing' => true]); in a one-time operation during deployment.

After that we can get the converted media using methods such as $mediaUrl = $orderable->getFirstMediaUrl('images', SmallMediaConversion::NAME) to get the right conversion for the right HTML container and its size (see retrieving converted images). Based on the designs and container, you decide which dimensions best fit that requirement.

Finally, we can use Tailwind's object-fit and object-position classes combined with a regular <img :src="mediaUrl" alt="alt text" />"` to display the image in the front-end.

Showing image placeholders

While it's great Spatie's media library provides collection fallback images and we already use our custom approach to this, it's probably better to let the back-end return the media (URL or responsive HTML) or null. That way the front-end can decide, for each case individually, if and how to display the media. If we always return an image or a fallback, we can never know when there isn't any. It also makes it impossible to show different placeholders for different parts of the app or different container sizes.

Temporary media

Temporary uploads are validated only once when selected or dropped onto the component. This includes basic validation (UUID, file type) and group-specific validation, such as category images (currently the only case).

When submitting the form, validated temporary uploads must be moved to their final destination. This is done using the HandlesMedia trait syncTemporaryFileUploads method, which accepts the UUIDs as an array (both new and existing media) and the collection we saved them to. The UUID combined with the group/collection ensures that users cannot bypass validation —for example, by copying a validated profile picture into a category image collection. Since validation occurs only once, such actions are not allowed.

Seeding images

First, make sure your filesystem disks in your local .env are correctly configured and seeding is enabled.

When working locally without a MinIO instance set up, this is a good default:

dotenv
FILESYSTEM_DISK=local
FILESYSTEM_CLOUD=public

SEED_IMAGES=true

Then make sure you have your storage folder linked using php artisan storage:link (or the reset command).

Running composer run-script reset or composer run-script fresh should then seed images for all models we support.

Writing media seeders

See \Database\Seeders\StoreLogoSeeder and \Database\Seeders\OrderableImagesSeeder for examples.