Pre-1.0. Not intended for public use yet. The API is subject to undocumented change, and these pages may already be wrong about it.

Skip to content

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 ​

app/StoryMiddleware/MarkReviewed.php
php
<?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:

routes/feed.php
php
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:

app/Providers/AppServiceProvider.phpboot()
php
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:

routes/feed.php
php
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:

routes/feed.php
php
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:

app/Providers/AppServiceProvider.phpboot()
php
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 ​

routes/feed.php
php
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.

DeclarationBatch Behaviour
nothingthe 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 ​

routes/feed.php
php
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 ​

app/StoryMiddleware/UseServiceActor.php
php
<?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:

PriorityActorContext
Call site->by($user) or explicit anonymity->context($model)
ScopeStoryfeed::actor() or storyfeed.actor:{Party}, including a scope carried into a queued jobStoryfeed::context() or storyfeed.context:{param}, including a scope carried into a queued job
Story middlewaresupplies an actor when hasActor() is falsesupplies context when has('context') is false
Verbthe verb's actornone
Resolver or usera custom actor_resolver; without one, the authenticated user, or in a queued job the user authenticated at dispatchnone
Fallbackparties.fallbacknone

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.

Released under the MIT License.