NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Reports for Laravel

DI, actions & DTOs

The facade is the recommended default, not the only way in. Three entry points run the same code:

  • The Reports facade — the shortest form.
  • The manager, injected through the constructor — the same API with an explicit dependency and no static calls. RoundlyConsulting\Reports\ReportsManager is the facade root, non-final so the fake can extend it.
  • Actions — single-purpose classes with execute(), resolvable from the container, for composing into your own jobs, services and commands.

Injecting the manager

Reports::fake() swaps the manager in the container too, so injected managers land on the fake:

use RoundlyConsulting\Reports\ReportsManager;

final class ReportPost
{
    public function __construct(private ReportsManager $reports) {}

    public function __invoke(User $user, Post $post): Report
    {
        return $this->reports->report($post)->by($user)->for('spam')->create();
    }
}

// The whole facade API is on the manager:
$this->reports->review($report);

Running an action

use RoundlyConsulting\Approvals\Enums\ApprovalRule;
use RoundlyConsulting\Reports\Actions\ChangeReportStatusAction;
use RoundlyConsulting\Reports\Actions\CreateReportAction;
use RoundlyConsulting\Reports\Actions\OpenModerationAction;
use RoundlyConsulting\Reports\Actions\PruneReportsAction;
use RoundlyConsulting\Reports\Actions\RejectReportAction;
use RoundlyConsulting\Reports\Actions\ResolveReportAction;
use RoundlyConsulting\Reports\DataTransferObjects\CreateReportData;
use RoundlyConsulting\Reports\DataTransferObjects\ResolveReportData;
use RoundlyConsulting\Reports\Enums\Reason;
use RoundlyConsulting\Reports\Enums\Status;

$report = app(CreateReportAction::class)->execute(new CreateReportData(
    subject: $post,
    reason: Reason::Spam,        // Reason|string, normalised to a slug
    reporter: $user,             // optional
    description: 'Looks like spam.',
    guestIdentifier: null,       // optional dedupe key for anonymous reports
));

app(ChangeReportStatusAction::class)->execute($report, Status::InReview);

app(ResolveReportAction::class)
    ->execute($report, new ResolveReportData(resolver: $moderator, note: 'Handled.'));

// or

app(RejectReportAction::class)
    ->execute($report, new ResolveReportData(resolver: $moderator, note: 'No violation.'));

app(OpenModerationAction::class)->execute($report, [$alice, $bob], ApprovalRule::Quorum, 1); // ApprovalRequest

app(PruneReportsAction::class)->execute(days: 90, force: false); // int

Facade method → action

Facade / manager methodAction
report($subject) / from($reporter)CreateReportAction (via ->create())
create(CreateReportData $data)CreateReportAction
moderate($report)OpenModerationAction (via ->open())
resolve($report, ?$by, ?$note)ResolveReportAction
reject($report, ?$by, ?$note)RejectReportAction
changeStatus($report, Status $status)ChangeReportStatusAction
review($report) / close($report)ChangeReportStatusAction
prune(?int $days = null, bool $force = false)PruneReportsAction
  • CreateReportAction — validates the reason, enforces duplicate prevention (filings against one subject are serialized), stores the report as Pending and checks the threshold.
  • ResolveReportAction / RejectReportAction — settle the report in one locked step checked against the stored status; while a moderation request is open, record a named moderator’s decision instead and refuse anyone else with ModeratorRequiredException.
  • ChangeReportStatusAction — owns every status move: a no-op on the same status, InvalidStatusTransitionException under strict_transitions, ModeratorRequiredException for a move out of the open statuses while a moderation request is open, otherwise the update and ReportStatusChanged.
  • OpenModerationAction — throws MissingModeratorsException without moderators, then opens the approvals request.
  • PruneReportsAction — soft-deletes, or with force permanently deletes, Resolved, Rejected and Closed reports created before the cutoff.

The reason reads have no action behind them. ReportsManager::openModeration() — the terminal of moderate()->open() — is @internal. Calling an action directly skips the manager, so Reports::fake() does not record it.

DTOs

final readonly class CreateReportData
{
    public string $reason; // normalised from Reason|string

    public function __construct(
        public Model $subject,
        Reason|string $reason,
        public ?Model $reporter = null,
        public ?string $description = null,
        public ?string $guestIdentifier = null,
    ) {}
}

final readonly class ResolveReportData
{
    public function __construct(
        public ?Model $resolver = null,
        public ?string $note = null,
    ) {}
}

CreateReportData normalises a Reason case to its slug, so $data->reason is always a string.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.