Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

Cruorzy's avatar
Level 14

Making sure a action is being called

Context:

I have a app where you can create a account, the account can have multiple users (and 1 owner, defined in the pivot table)

You can leave a account, but when you leave a account and no other users are left the account should be deleted.

LeaveAccount.php

<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Models\Account;
use Illuminate\Support\Facades\Auth;
use Lorisleiva\Actions\Concerns\AsAction;

final readonly class LeaveAccount
{
    use AsAction;

    /**
     * Leave the account.
     */
    public function handle(Account $account): void
    {
        $account->users()->detach(Auth::user());

        if ($account->users()->count() === 0) {
            DeleteAccount::run($account);
        }
    }
}

DeleteAccount

<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Models\Account;
use Illuminate\Support\Facades\Gate;
use Lorisleiva\Actions\Concerns\AsAction;

final readonly class DeleteAccount
{
    use AsAction;

    /**
     * Delete the given account.
     */
    public function handle(Account $account): void
    {
        Gate::authorize('delete', $account);

        $account->users()->detach();

        $account->delete();
    }
}

How can I ensure LeaveAccount calls DeleteAccount::run() instead of simply just doing a $account->delete(); on its own

The tests will be successful either way but I want to make sure the action is being called.

I was thinking about dispatching a event in the DeleteAccount action and then checking in the tests that it was called.

But seems a bit extra for just testing purposes.

0 likes
7 replies
LaryAI's avatar
Level 58

To ensure that LeaveAccount calls DeleteAccount::run() instead of directly deleting the account, you can use a mocking approach in your tests. This way, you can verify that the DeleteAccount::run() method is indeed being called. Here's how you can achieve this using PHPUnit and Laravel's built-in testing capabilities:

  1. Mock the DeleteAccount action: You can use Laravel's Mockery to create a mock of the DeleteAccount class and set expectations on it.

  2. Set expectations: Ensure that the run method is called with the correct parameters.

Here's an example of how you might write a test for this:

Explanation:

  • Mockery: We use Mockery to create a mock of the DeleteAccount class. This allows us to set expectations on the run method.

  • Expectation: We expect the run method to be called exactly once with an instance of Account.

  • Binding the Mock: We bind the mock to the Laravel service container, so when DeleteAccount::run() is called, it uses our mock instead of the actual class.

  • Test Execution: We simulate the scenario where a user leaves an account, and we verify that DeleteAccount::run() is called when the account has no users left.

This approach ensures that your test will fail if LeaveAccount does not call DeleteAccount::run(), thus enforcing the desired behavior.

Cruorzy's avatar
Level 14

@LaryAI Trying to mock the class now with the following code

it('make sure it calls the DeleteAccount action when no users are present', function () {
    $deleteAccount = Mockery::mock(DeleteAccount::class);
    $deleteAccount->shouldReceive('run')->once();

    LeaveAccount::run($this->account);
});

The following error appears

  Method run(<Any Arguments>) from Mockery_2_App_Actions_Account_DeleteAccount should be called
 exactly 1 times but called 0 times.
Cruorzy's avatar
Level 14

Can someone pin me in the right direction?

martinbean's avatar
Level 80

@cruorzy Test behaviour, not implementations.

If the last user leaving account should cause that account to be deleted, then test that:

public function test_account_is_deleted_if_last_user_leaves(): void
{
    $user = User::factory()->create();
    $account = Account::factory()->create();
    $account->users()->attach($user);

    // Remove user from account...

    // Assert the account is now deleted...
    $this->assertModelMissing($account);
}

I also wouldn’t make your LeaveAccount action rely on the authenticated user. That just completely defeats the point of having a re-usable action class that can be used throughout your application, because you’ve restricted it to only being able to be used in HTTP contexts. You can’t use it in Artisan commands or queued jobs, because there is no notion of an “authenticated user” in those contexts.

You’d be much better off just having an AccountService class that handles adding and removing users:

class AccountService
{
    public function addUser(Account $account, User $user)
    {
        $account->users()->attach($account);

        UserAddedToAccount::dispatch($account, $user);
    }

    public function removeUser(Account $account, User $user)
    {
        $account->users()->detach($account);

        UserRemovedFromAccount::dispatch($account, $user);
    }
}

You can then have a listener on the UserRemovedFromAccount event that will delete the account if it no longer has any users:

public function handle(UserRemovedFromAccount $event)
{
    if ($event->account->users()->count() === 0) {
        $event->account->delete();
    }
}

Individual action classes are fine… if you actually use them for their benefits. But you negating their benefits by restricting it to HTTP contexts and the requirement for an authenticated user, which does not make it that re-usable at all.

Cruorzy's avatar
Level 14

@martinbean Thanks!

Its hard to let some specific tests just go, because I tend to want to be really specific.

I'm new to using actions but thanks for the feedback! Going to make them not based on the auth user.

I do like the code inside a action to structure tests and not have to look for events or observers to get this done.

Cruorzy's avatar
Level 14

@martinbean

Do you use authorization on the actions, having thought on it now since I am not sure how to do it properly without a authenticated user

Action

<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Models\Account;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Lorisleiva\Actions\Concerns\AsAction;

final readonly class AddUserToAccount
{
    use AsAction;

    /**
     * Add the given user to the account.
     */
    private function handle(Account $account, User $byUser, User $addUser): void
    {
        Gate::authorize('add-user', [$account, $byUser, $addUser]);

        $account->users()->attach($addUser);
    }
}

Policy

/**
     * Determine if the given user can be added to the account.
     */
    public function addUser(?User $authUser, Account $account, User $byUser, User $addUser): Response
    {
        if (! $byUser->isOwnerOfAccount($account)) {
            return Response::deny('You are not the owner of this account.');
        }

        if ($account->users()->get()->contains($addUser)) {
            return Response::deny('This user is already added to the account.');
        }

        return Response::allow();
    }
martinbean's avatar

@Cruorzy Not really. As you say, that depends on their being a notion of an authenticated user to be able to check their permissions.

I’ll do any authorisation in the controller (usually via middleware). The controller then just calls any business logic, like it would if any authentication checks passed, or validation in a form request passed, etc.

Please or to participate in this conversation.