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); // intFacade method → action
| Facade / manager method | Action |
|---|---|
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 cryptoBy 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.