Story Middleware & Batching
Introduction
Use story middleware to add shared data, supply roles, or decide whether to publish an activity. The built-in batch middleware collects one actor's activities into a batch.
Defining Story Middleware
<?php
namespace App\StoryMiddleware;
use Closure;
use Storyfeed\PendingActivity;
class MarkReviewed
{
public function handle(PendingActivity $activity, Closure $next): mixed
{
$activity->data([
...($activity->activity->data ?? []),
'reviewed' => true,
]);
return $next($activity);
}
}Middleware receives a PendingActivity and passes it to $next. Code after $next($activity) can inspect the returned activity. Check its exists property before performing work that requires a stored activity.
Return null without calling $next to skip publishing. The builder's publish method then returns an unsaved activity (exists === false). To continue publishing, return the result of $next($activity). Middleware cannot change the verb or object type.
Closure Middleware
You may also define middleware as a closure:
use App\Models\Order;
use Closure;
use Storyfeed\Facades\Story;
use Storyfeed\PendingActivity;
Story::for(Order::class)->verb('place')
->headline(':actor placed :object with :target')
->icon('shopping-bag')
->middleware(
static function (PendingActivity $activity, Closure $next) {
return $next($activity->data([
...($activity->activity->data ?? []),
'reviewed' => true,
]));
},
);[
{
"kind": "activity",
"id": "j84",
"verb": "place",
"published_at": "1985-07-02T12:00:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": null,
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "order",
"id": "1035",
"label": "Order #1035",
"url": "/orders/1035",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {
"reviewed": true
},
"tombstoned": [],
"redundant": false,
"missing_headline_template": null,
"missing_headline": null
}
]Storyfeed::fake() runs the same middleware pipeline.
Registering Middleware
Aliases
Register aliases and named groups in a service provider:
use App\StoryMiddleware\MarkReviewed;
use Storyfeed\Facades\Story;
// Register here: cached stories do not load routes/feed.php.
Story::aliasMiddleware('reviewed', MarkReviewed::class);
Story::middlewareGroup('review', ['reviewed']);Groups
The middlewareGroup method registers a list of middleware under one name. The review group contains the reviewed alias; either may be assigned to a story.
Assigning Middleware to Stories
Attach the class to the verb:
use App\Models\Order;
use App\StoryMiddleware\MarkReviewed;
use Storyfeed\Facades\Story;
Story::for(Order::class)->verb('place')
->headline(':actor placed :object with :target')
->icon('shopping-bag')
->middleware(MarkReviewed::class);[
{
"kind": "activity",
"id": "j84",
"verb": "place",
"published_at": "1985-07-02T12:00:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": null,
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "order",
"id": "1035",
"label": "Order #1035",
"url": "/orders/1035",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {
"reviewed": true
},
"tombstoned": [],
"redundant": false,
"missing_headline_template": null,
"missing_headline": null
}
]Use the group in the feed file:
use App\Models\Order;
use Storyfeed\Facades\Story;
Story::middleware('review')->group(function () {
Story::for(Order::class)->verb('place')
->headline(':actor placed :object with :target')
->icon('shopping-bag');
Story::for(Order::class)->verb('complete')
->headline(':actor completed :object')
->withoutMiddleware('reviewed');
});[
{
"kind": "activity",
"id": "j84",
"verb": "place",
"published_at": "1985-07-02T12:00:00.000000Z",
"headline_template": ":actor placed :object with :target",
"headline": null,
"glyph": "shopping-bag",
"glyph_intent": null,
"actor": {
"type": "user",
"id": "113",
"label": "Erica Sinclair",
"url": "/users/113",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"object": {
"type": "order",
"id": "1035",
"label": "Order #1035",
"url": "/orders/1035",
"attributes": {},
"modal": false,
"data": {},
"media": null,
"body": null,
"tombstone": null
},
"target": {
"type": "venue",
"id": "101",
"label": "Scoops Ahoy",
"url": "/venues/scoops",
"attributes": {},
"modal": false,
"data": {},
"media": {
"icon": null,
"image": null,
"files": [],
"preview": {
"src": "/media/worlds/stranger-things/parlour.jpg",
"mediaType": "image/jpeg",
"width": 960,
"height": 720,
"alt": "An ice cream counter"
},
"url": null
},
"body": [
{
"$body": "Storyfeed/Body/Image",
"$v": 1,
"caption": "Scoops Ahoy",
"alt": "Scoops Ahoy",
"width": null,
"height": null,
"image": "preview"
}
],
"tombstone": null
},
"context": null,
"origin": null,
"result": null,
"instrument": null,
"data": {
"reviewed": true
},
"tombstoned": [],
"redundant": false,
"missing_headline_template": null,
"missing_headline": null
}
]Execution Order
The place activity receives the review data; complete skips that middleware. The built-in default group runs first, followed by enclosing groups and the verb's middleware. Identical resolved middleware strings run once. The default group contains batch; redefine it to configure middleware for every verb:
use Storyfeed\Facades\Story;
Story::middlewareGroup('default', ['batch', 'reviewed']);A queued activity runs its story middleware on the worker.
Excluding Middleware
Exclusions match resolved strings exactly: withoutMiddleware('batch') leaves batch:5 minutes in place. Use unbatched() to remove batching altogether.
Parameters
A middleware string may name a class, alias, or group. Append arguments after a colon, as in batch:5 minutes. A custom class receives them after $next in its handle method. A Story may declare middleware in middleware(): array, but that method cannot use constructor data.
Batching Activities
Batch Windows
use App\Models\Order;
use Storyfeed\Facades\Story;
Story::for(Order::class)->verb('place')
->headline(':actor placed :object with :target')
->icon('shopping-bag')
->batched(within: '5 minutes');A batch collects activities by one actor until its window closes. The activity joins the actor's open batch without changing its headline. Storyfeed batches are separate from Laravel job batches created with Bus::batch().
The window determines how long Storyfeed waits for more activities before closing the batch. Each batched activity extends the closing time to its published_at plus its verb's window, if that is later. An activity joins an available open batch only when opened_at <= published_at < closes_at. An out-of-order arrival before an available window, or an activity at or after its closing time, starts a separate batch. Closed batches are not reopened. Anonymous activities cannot join a batch because they have no recorded actor. Batches are stored in feed_batches.
| Declaration | Batch Behaviour |
|---|---|
| nothing | the built-in batch middleware uses grouping.batch.quiet_minutes |
batched() | uses the configured window |
batched(within: '5 minutes') | uses a five-minute window |
unbatched() | does not join, extend, or close a batch |
within accepts a positive interval string or a DateInterval. storyfeed.grouping.batch.enabled = false disables batching.
The examples below are alternative declarations for place. Replace its existing declaration when trying one.
Publishing Outside a Batch
use App\Models\Order;
use Storyfeed\Facades\Story;
Story::for(Order::class)->verb('place')
->headline(':actor placed :object with :target')
->icon('shopping-bag')
->unbatched();The activity remains in the feed without affecting the actor's open batch. The unbatched method removes batch middleware, including inherited windows.
Listening for Closed Batches
When a batch closes, Storyfeed dispatches Storyfeed\Events\BatchClosed after the outermost transaction commits. The event's $event->batch contains an immutable copy of the closed batch and its activities. Register a listener to handle the completed batch.
Supplying Default Roles
<?php
namespace App\StoryMiddleware;
use Closure;
use Storyfeed\PendingActivity;
class UseServiceActor
{
public function handle(PendingActivity $activity, Closure $next): mixed
{
if (! $activity->hasActor()) {
$activity->by('System'); // A declared party.
}
return $next($activity);
}
}Call hasActor() before supplying an actor; it also returns true for explicit anonymity. Call has('context') before supplying context. These checks preserve actor and context precedence. Declare party names in the party list, because the by method does not check that list.
Resolving Role Precedence
Storyfeed resolves each role from the first applicable source:
| Priority | Actor | Context |
|---|---|---|
| Call site | ->by($user) or explicit anonymity | ->context($model) |
| Scope | Storyfeed::actor() or storyfeed.actor:{Party}, including a scope carried into a queued job | Storyfeed::context() or storyfeed.context:{param}, including a scope carried into a queued job |
| Story middleware | supplies an actor when hasActor() is false | supplies context when has('context') is false |
| Verb | the verb's actor | none |
| Resolver or user | a custom actor_resolver; without one, the authenticated user, or in a queued job the user authenticated at dispatch | none |
| Fallback | parties.fallback | none |
A custom resolver replaces the authenticated user as a source. If it returns null, the fallback party applies. Explicit anonymity records no actor. Without a resolved actor, the activity is anonymous and cannot join a batch. If no context is supplied, that role remains empty.
Caching and Inspecting Middleware
The storyfeed:cache command caches middleware declarations, including closures. Register aliases and named groups in a service provider so they remain available when the feed file is cached. Use storyfeed:list -v to inspect each verb's resolved middleware and arguments.