Action class usage in my Laravel Apps

Action class usage in my Laravel Apps

What is an action class?

I define an action class as exactly that – a class that performs a single action. Examples include addUser, deleteUser, assignRole etc.

In my applications I typically make these classes invokable. However, there will be the odd case where I choose to add a method to call, it depends on usage and how the code reads.

Action class structure

The structure of my classes very much depends on the structure of the App itself. Is the App a standard app with its own database or does it pass everything off to a REST API and effectively acts as the front-end?

Action class usage in a typical App

In a typical App my action classes return a Boolean. I keep things simple, the action either succeeds or doesn’t. Error handling is either handled by the controller or internally within the action, it depends on how exceptions are being handled and whether actions need to be retried, this is typically a business decision.

Below is a simple example which sets the salesperson for an order.

class AssignSalesPerson
{
    public function __invoke(
        int $customer_id,
        string $order_reference,
        array $input
    ): bool {
        Validator::make($input, [
            'sales_person_code' => [
                'required',
                Rule::exists(User::class, 'sales_person_code')
            ]
        ])->validate();

        $order = (new Order())->getOrder($customer_id, $reference);
        if ($order === null) {
            return false;
        }

        try {
            DB::transaction(function () use ($order, $input) {
                // Update the order
                $order->sales_person_code = $input['sales_person_code'];
                $order->save();
                
                // Log the order change
                // ...   
            });

            return true;
            
        } catch (\Exception $e) {
            // Log the exception the error
            //...
            return false;
        }
    }
}

Action class usage with a REST API

If the App is a frontend for a REST API, the structure of my action classes changes. Instead of returning a Boolean, they return an integer, specifically the http status code returned from the API request. Additionally, these action classes will extend a base class which will contain several helper methods and properties.

Below is an example of the parent for API action classes.

abstract class Action
{
    // The message to return to the user, this is usually the message
    // from the the API when the request didn't succeed
    protected string $message;

    // Store any validation errors from the API
    // Our App can then pass these on as necessary
    protected array $validation_errors = [];

    // Store any parameters that need to be passed to the view
    // For example the id for a new resource 
    protected array $parameters = [];

    public function getMessage(): string
    {
        return $this->message;
    }

    public function getParameters(): array
    {
        return $this->parameters;
    }

    public function getValidationErrors(): array
    {
        return $this->validation_errors;
    }
}

And this is an example of a class which creates a budget item.

class CreateBudgetItem extends Action
{
    public function __invoke(
        Service $api,
        string $resource_type_id,
        string $resource_id,
        array $input
    ): int {
        // Handle any validation which needs to be 
        // done locally before passing the data to the API

        if (count($this->validation_errors) > 0) {
            return 422;
        }

        // Create the payload to send to the API        
        $payload = [
            ...
        ];

        $create_response = $api->budgetItemCreate(
            $resource_type_id,
            $resource_id,
            $payload
        );

        if ($create_response['status'] === 201) {
            return $create_response['status'];
        }

        if ($create_response['status'] === 422) {
            $this->message = $create_response['content'];
            $this->validation_errors = $create_response['fields'];

            return $create_response['status'];
        }

        // Unexpected failure, store the message
        $this->message = $create_response['content'];
        return $create_response['status'];
    }
}

The names of my classes depend on the complexity of the App. The class names shown above are simply examples, typically I try to organise my code by namespace and keep the names simple. If you see \App\Actions\BudgetItem\Create you know exactly what the action class is doing, there is no way to misinterpret the desired action.

Benefits

There are several benefits to using action classes – I’m going to talk about two for now, in the future I might do a full deep dive into all the positives and negatives.

Encapsulation

The class encapsulates all the code for the function, if you are strict in how you use actions this is a massive plus, there is a single class that performs the action, and it is easily testable.

Reusability

Action classes are reusable, it is easy to call an action class in a console command or job, I’ve several actions that need to be called on command as well as based on a user action. If I didn’t use action classes I would have had to create something similar or worse, duplicate the relevant code.

Continue to evolve

My action classes have evolved over time and I’m sure I will make more tweaks in the future. Much of my client work is creating B2B Apps and typically they require event logging, transactions, retrying. My real-life action classes are much more complex than the examples detailed above.

As much as I love action classes, they are not always the best solution. I try to ensure my action classes stay small. In cases where that is not possible and you still want to use the same idea, invokable controllers can help.

This post is part of a series about my development process. Please check out my typical project setup and my Git workflow.

My typical project setup for Laravel

My Laravel project setup for Windows, Docker, WSL2 & PHPStorm

This is one of those posts that is more of a reference for me rather than general information. If you use PHP, Docker, MySQL, Windows, WSL2 and PHPStorm this post about my typical Laravel project setup may be of interest to you.

Docker and WSL2

I use Docker, specifically docker compose. Typically, I define two services in the docker-compose.yml file, [project_name].app and [project_name].mysql. If the project is part of a service I will define a network, this is to make it easier for containers to communicate. All the Costs to Expect Apps rely on the Costs to Expect API so they all share the same network.

Below is an example of a docker-compose.yml file for Budget, our free budgeting tool.

version: '3'
services:
    costs.budget.app:
        build:
            context: .
            dockerfile: .docker/app/Dockerfile
        image: costs.budget.app
        container_name: costs.budget.app
        ports:
            - "80:80"
        volumes:
            - .:/var/www/html
        env_file: .env
        environment:
            TZ: UTC
            DB_HOST: ${DB_HOST}
            DB_DATABASE: ${DB_DATABASE}
            DB_USERNAME: ${DB_USERNAME}
            DB_PASSWORD: ${DB_PASSWORD}
    costs.budget.mysql:
        build:
            context: .
            dockerfile: .docker/mysql/Dockerfile
        image: costs.budget.mysql
        container_name: costs.budget.mysql
        ports:
            - "3306:3306"
        env_file: .env
        environment:
            TZ: UTC
            MYSQL_DATABASE: ${DB_DATABASE}
            MYSQL_USER: ${DB_USERNAME}
            MYSQL_PASSWORD: ${DB_PASSWORD}
            MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
        volumes:
            - ./.docker/mysql/data:/var/lib/mysql
networks:
    default:
        name: costs.network
        external: true

I leave the ports at their default values. The exception is when I know I will need to run more than Docker container at a which need to communicate. I always need to run the Costs to Expect API at the same time as another Costs to Expect App so will typically map the ports on the API to 8080 and 3308.

The dockerfiles for each of the services exist in a .docker folder. I typically end up with .docker/app/Dockerfile and .docker/mysql/Dockerfile. The .docker/mysql folder will also contain a data folder, this is the volume for the MySQL data.

The MySQL Dockerfile doesn’t include any configuration, just FROM mysql:8, the example below shows a Dockerfile for a typically Laravel application.

I used to include Composer and PHPUnit but have moved away from the additional complexity as it didn’t really offer any benefits and just made configuration in PPHStorm more complicated.

FROM php:8.1-apache

COPY . /var/www/html
COPY .docker/app/vhost.conf /etc/apache2/sites-available/000-default.conf

RUN apt-get update && apt-get install -y \
        libfreetype6-dev \
        libjpeg62-turbo-dev \
        libpng-dev \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) gd

RUN apt-get update && apt-get install -y \
    zip libzip-dev \
    && docker-php-ext-configure zip \
    && docker-php-ext-install zip

RUN docker-php-ext-install pdo_mysql bcmath

RUN chown -R www-data:www-data /var/www/html \
    && a2enmod rewrite

WORKDIR /var/www/html

PHPStorm, Composer and PHPUnit

PHPStorm works relatively flawlessly with WSL2, well it did and will again without a tweak after the 16th of January.

I store all my project files in WSL2, specifically Ubuntu, the project root for PHPStorm will normally be something like \\wsl$\Ubuntu\home\[user]\Projects\[project_name]. I have a Projects folder inside my home directory and a folder for each project. The Projects folder includes any phar files I might need so as not to duplicate them across projects.

The CLI Interpreter is set to my distro of choice, so in my case Ubuntu.

Composer settings are as defined as below.

  • Path to composer.json //wsl$/Ubuntu/home/[user]/Projects/[project_name]/composer.json
  • Execution, composer.phar
  • Composer.phar location \wsl$\Ubuntu\home\[user]\Projects\composer.phar

PHPUnit settings are defined as below

  • Remote Interpreter
  • Use composer autoloader
  • Path to script /home/[user]/Projects/[project_name]/vendor/autoload.php
  • Test runner default configuration file /home/[user]/Projects/[project_name]/phpunit.xml.dist

This Laravel project setup works well for all my projects as if I ever need to use a different version of PHP or MySQL I can update the dockerfiles for each service and rebuild. I recently needed to upgrade an App form PHP5.6 to 8.0, this setup made it easy to upgrade to each version of PHP and MySQL along the way and test at each step.

If I need to use Tailwind, SASS or other tools, I try to use them via WSL2 before going the Windows route.

After reading this you may wonder why I use Windows for development, there are three main reasons, one, I’m comfortable with Windows, two, I play games, three, I build my own PCs. The setup above will work regardless of your operating system of choice.

This post is part of a series on my development process. Please check out some of my other posts in this series, my git workflow and action class usage in my Laravel apps.