Perfocard Flow is a lightweight, extensible package for managing the statuses of business objects and archiving large payloads in Laravel projects.
Overview:
- Centralized enum-based status model and lifecycle management for business objects.
- Automatic archiving of large payloads to remote storage (ZIP) to reduce database size, with restore and purge capabilities.
- Integration with Laravel Nova: built-in resources, a status field, actions (Compress / Restore / Purge / Defibrillate) and universal metrics.
- Support for asynchronous processing via Tasks, Endpoints and queued listeners/jobs; includes stubs for quick setup.
- Installer that publishes configuration and stubs, plus scheduler commands (
flow:compress
,flow:purge
).
Benefits:
- Clean architecture for handling long-running and failed processes (defibrillation + events).
- Reduced database size through payload archiving.
- Ready-made Nova integration to speed up administrative workflows.
- Task/Endpoint structure that simplifies testing and scaling.
Use cases:
- When object statuses require complex business flows.
- When payloads grow large and bloat the database.
- When interactive administration via Nova is needed.
- Perfocard Flow
- Contents
- 1. Installation
- 2. Configuration
- 3. Usage
- 4. Laravel Nova integration
composer require perfocard/flow
The package publishes the following stubs:
endpoint.stub
enum.stub
listener.queued.stub
listener.typed.queued.stub
migration.create.stub
model.stub
status.stub
task.stub
nova/resource.stub
php artisan flow:install
php artisan migrate
In your application make App\Nova\Resource
extend the package resource instead of the default Nova resource. In app/Nova/Resource.php
add or change the import and the base class:
use Perfocard\Flow\Nova\Resource as FlowNovaResource;
abstract class Resource extends FlowNovaResource
{
// ...
}
Add the Status
resource in NovaServiceProvider::resources()
so it appears in Nova (this method is absent by default, so you will most likely need to add it manually):
class NovaServiceProvider extends NovaApplicationServiceProvider
{
// ... other methods
public function resources()
{
parent::resources();
Nova::resources([
\Perfocard\Flow\Nova\Resources\Status::class,
]);
}
}
In config/flow.php
specify the disks used for compression:
compression.disk.remote
— the disk for storing finished ZIP archives (e.g.s3
).compression.disk.temp
— a temporary disk for creating archives (e.g.local
). You can define a separate disk for temporary files if needed.
config/filesystems.php
and have read/write permissions. Do not use the same disk for both remote
and temp
.
Example .env variables
FLOW_COMPRESSION_DISK_REMOTE=s3
FLOW_COMPRESSION_DISK_TEMP=local
FLOW_COMPRESSION_TIMEOUT=2880
FLOW_PURGE_TIMEOUT=2880
Add scheduled tasks to automatically run the package commands. This is needed to save database space: payloads from the statuses table will be moved to the remote filesystem and archived there automatically.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('flow:compress')->everyMinute();
Schedule::command('flow:purge')->everyMinute();
Remember: in production you must have cron that runs php artisan schedule:run
every minute.
All examples below use a fictional Document
model to demonstrate practical scenarios and how the package works.
In Laravel you can generate a model and migration with the standard command:
php artisan make:model Document -m
This command will create the model class app/Models/Document.php
and a migration file in database/migrations
.
return new class extends Migration
{
public function up(): void
{
Schema::create('documents', function (Blueprint $table) {
$table->id();
// Core field for the Perfocard Flow package
$table->unsignedTinyInteger('status');
// Other fields
$table->string('title');
$table->text('content')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('documents');
}
};
When you generate the model using the published package stub, the generated model already extends the package base model Perfocard\\Flow\\Models\\FlowModel
. No manual change is required.
// app/Models/Document.php
use Perfocard\Flow\Models\FlowModel;
class Document extends FlowModel
{
protected $fillable = [
'status',
'title',
'content',
];
}
Using
FlowModel
enables the package to manage statuses and automatically integrate the business process into your model.
For the Document
model you need to create a corresponding status enum class. Generate it using the command:
php artisan make:status DocumentStatus
This will generate app/Models/DocumentStatus.php
, where you will describe all possible statuses and their transitions. The file will be located next to the Document
model.
// app/Models/DocumentStatus.php
use Perfocard\Flow\Contracts\ShouldBeDefibrillated;
use Perfocard\Flow\Contracts\ShouldDispatchEvents;
use Perfocard\Flow\Contracts\BackedEnum;
use Perfocard\Flow\Traits\IsBackedEnum;
enum DocumentStatus: int implements BackedEnum, ShouldBeDefibrillated, ShouldDispatchEvents
{
use IsBackedEnum;
case QUEUED = 0;
case PROCESSING = 1;
case ERROR = 2;
case COMPLETE = 3;
// ... other methods, not necessary for this example
}
After generating the status enum you should add the attributes and the casts()
method to the Document
model. This sets a default status and casts the attribute to the enum class:
// app/Models/Document.php
class Document extends FlowModel
{
// ... other properties and methods
protected $attributes = [
'status' => DocumentStatus::QUEUED,
];
protected function casts(): array
{
return [
'status' => DocumentStatus::class,
];
}
}
You need an event to be dispatched when a document is queued. Use:
php artisan make:event DocumentQueued
This will create app/Events/DocumentQueued.php
. Add the Document
model to the generated event constructor:
// app/Events/DocumentQueued.php
use App\Models\Document;
class DocumentQueued
{
public function __construct(
public Document $document,
) {}
}
Also register this event in the DocumentStatus
class in the events()
method. Example:
// app/Models/DocumentStatus.php
use App\Events\DocumentQueued;
enum DocumentStatus: int
{
// ... other methods and properties
public function events(): array
{
return match ($this) {
self::QUEUED => [
DocumentQueued::class,
],
default => [],
};
}
}
When a document is created it will dispatch the DocumentQueued
event. You need a listener to handle this event.
Run:
php artisan make:listener Document/ProcessQueuedDocument --event=DocumentQueued --queued
This will generate app/Listeners/Document/ProcessQueuedDocument.php
. The listener will listen for the event and run in the queue. Example implementation:
// app/Listeners/Document/ProcessQueuedDocument.php
use App\Models\DocumentStatus;
class ProcessQueuedDocument
{
public function handle(DocumentQueued $event): void
{
$event->document->setStatusAndSave(
status: DocumentStatus::PROCESSING,
);
$event->document->content = 'Lorem ipsum dolor sit amet.';
$event->document->setStatusAndSave(
status: DocumentStatus::COMPLETE,
);
}
/**
* Handle a job failure.
*/
public function failed(DocumentQueued $event, Throwable $exception): void
{
parent::saveException(
resource: $event->document,
status: DocumentStatus::ERROR,
exception: $exception,
);
throw $exception;
}
}
An alternative approach is to move processing logic to a separate Task class.
Generate the class:
php artisan make:task DocumentGenerator -m Document
This will create app/Tasks/DocumentGenerator.php
.
Example implementation:
// app/Tasks/DocumentGenerator.php
use App\Models\DocumentStatus;
class DocumentGenerator implements HandledTask
{
public function processing(FlowModel $model): BackedEnum
{
return DocumentStatus::PROCESSING;
}
public function complete(FlowModel $model): BackedEnum
{
return DocumentStatus::COMPLETE;
}
public function handle(FlowModel $model): FlowModel
{
$model->content = 'Lorem ipsum dolor sit amet.';
return $model;
}
}
After creating DocumentGenerator
you can use it in the listener instead of inline logic. Example:
// app/Listeners/Document/ProcessQueuedDocument.php
use Perfocard\Flow\Task;
use App\Tasks\DocumentGenerator;
class ProcessQueuedDocument
{
public function handle(DocumentQueued $event): void
{
Task::for(DocumentGenerator::class)
->on($event->document)
->dispatch();
}
public function failed(DocumentQueued $event, Throwable $exception): void
{
parent::saveException(
resource: $event->document,
status: DocumentStatus::ERROR,
exception: $exception,
);
throw $exception;
}
}
In this case, the status will be automatically set to PROCESSING
before the task starts and to COMPLETE
after it is finishes.
Another implementation option is to use an Endpoint when document content must be fetched from an external HTTP service. Generate an endpoint class:
php artisan make:endpoint ExternalDocumentContent -m Document
This will create app/Endpoints/ExternalDocumentContent.php
.
Example implementation:
// app/Endpoints/ExternalDocumentContent.php
use App\Models\DocumentStatus;
class ExternalDocumentContent
{
public function processing(): BackedEnum
{
return DocumentStatus::PROCESSING;
}
public function complete(): BackedEnum
{
return DocumentStatus::COMPLETE;
}
public function method(FlowModel $model): string
{
return 'POST';
}
public function url(FlowModel $model): string
{
return 'https://example.com/api/generate';
}
public function buildPayload(FlowModel $model): array
{
return [
'title' => $model->title,
];
}
public function processResponse(Response $response, FlowModel $model): FlowModel
{
$model->content = $response->json('content');
return $model;
}
}
After creating ExternalDocumentContent
you can use it in a listener instead of inline logic. Example:
// app/Listeners/Document/ProcessQueuedDocument.php
use Perfocard\Flow\Endpoint;
use App\Endpoints\ExternalDocumentContent;
class ProcessQueuedDocument extends ThrowableListener
{
public function handle(DocumentQueued $event): void
{
Endpoint::for(ExternalDocumentContent::class)
->on($event->document)
->dispatch();
}
public function failed(DocumentQueued $event, Throwable $exception): void
{
parent::saveException(
resource: $event->document,
status: DocumentStatus::ERROR,
exception: $exception,
);
throw $exception;
}
}
In this case, the status will automatically be set to PROCESSING
before the call and to COMPLETE
after it finishes. Additionally, the request payload and the response are automatically saved to the payload
field of the corresponding status record.
This package ships with built-in support for Laravel Nova and provides resources, fields, filters and some universal metrics out of the box.
All statuses for different models are stored in a separate statuses
table. The package includes a model and a Nova resource Status
and supports polymorphic relations with your models.
If you need to view all statuses in Laravel Nova, enable it in the configuration:
// config/flow.php
return [
'status' => [
// ... other settings
'nova_navigation' => true,
],
];
After that the Status resource will appear in your Nova panel.
When installing the package a stub for the Nova resource is published or replaced. As a result, newly generated Nova resources include modified methods.
When generating a resource run:
php artisan nova:resource Document
Note the fields method — instead of directly returning an array it calls the parent mergeFields
method:
// app/Nova/Document.php
class Document extends Resource
{
public function fields(NovaRequest $request)
{
return parent::mergeFields([
//
]);
}
}
This allows the status field to be automatically displayed in Nova without manual definition. This is the default behavior and should work for most cases. In special cases you can return a regular array to revert to Nova's default behavior.
The package provides several Nova actions out of the box.
This feature restarts the business process. To use it, implement the defibrillation
method in your status enum and ensure it implements the ShouldBeDefibrillated
contract:
// app/Models/DocumentStatus.php
use Perfocard\Flow\Contracts\ShouldBeDefibrillated;
enum DocumentStatus: int implements ShouldBeDefibrillated
{
public function defibrillate(): ?self
{
return match ($this) {
self::ERROR => self::QUEUED,
default => null,
};
}
}
Now, if a resource has the ERROR
status, Nova will show the Defibrillate
action on that resource and executing it will change the status from ERROR
to QUEUED
and the business process will be restarted.
You can also defibrillate programmatically:
use App\Models\Document;
use App\Models\DocumentStatus;
$document = Document::where('status', DocumentStatus::ERROR)->first();
// defibrillate
$document->defibrillate();
Statuses contain a payload
field which can grow large over time (for example when using an Endpoint, both the request and response can be stored). To avoid large database growth use the archiving feature.
If you configure the scheduler to run php artisan flow:compress
regularly this process will run automatically.
If you need to archive payloads manually (for example before the scheduled compress run) use the Compress
action in Nova. This will archive the payload immediately. The action is available on a specific status resource and only when the payload has not yet been archived.
Once payloads are archived they are no longer present in the database field. To restore them for analysis use the Restore
action. This temporarily restores the payload into the database field.
After restoring a payload it again occupies database space. Use the Purge
action to remove the payload permanently when it is no longer needed.
If you schedule php artisan flow:purge
, purging will run automatically.
The package ships with three universal metrics. Below we describe each of them.
A universal metric implementing the Laravel\Nova\Metrics\Value
metric.
Example usage:
// app/Nova/Document.php
use Perfocard\Flow\Nova\Metrics\NewItems;
class Document extends Resource
{
public function cards(NovaRequest $request)
{
return [
NewItems::make(__('New documents'), self::$model),
];
}
}
A universal metric implementing the Laravel\Nova\Metrics\Trend
metric.
Example usage:
// app/Nova/Document.php
use Perfocard\Flow\Nova\Metrics\ItemsPerDay;
class Document extends Resource
{
public function cards(NovaRequest $request)
{
return [
ItemsPerDay::make(__('Documents per day'), self::$model),
];
}
}
A universal metric implementing the Laravel\Nova\Metrics\Partition
metric.
Example usage:
// app/Nova/Document.php
use Perfocard\Flow\Nova\Metrics\ItemsByEnum;
use App\Models\DocumentStatus;
class Document extends Resource
{
public function cards(NovaRequest $request)
{
return [
ItemsByEnum::make(__('Documents by status'), self::$model, DocumentStatus::class, 'status'),
];
}
}