Aggregation
Introduction
Aggregation combines related activities into one feed item, so three orders from one customer appear as one row.
Grouping Activities
Repeated Activities
Here are three orders from one customer, minutes apart, in log mode:
Define a headline with grouped() to describe them together:
use App\Models\Order;
use Storyfeed\Facades\Story;
use Storyfeed\Grouping\GroupBuilder;
Story::for(Order::class)
->verb('place')
->grouped(
fn (GroupBuilder $group) => $group
->repeat(':actor placed :count orders with :target'),
);Activities Sharing a Role
Five customers ordering from the same shop need a different sentence. Each group names an axis: what its activities have in common.
use Storyfeed\Facades\Story;
use Storyfeed\Grouping\GroupBuilder;
Story::verb('place')->grouped(
fn (GroupBuilder $group) => $group
->actors(':actors ordered from :target'),
);A repeat group contains one type, so its headline belongs under Story::for(Order::class) and can say "orders". An actors group may also contain reservations, so its headline belongs on the verb alone and must not name a type.
Storyfeed groups activities when published. Each activity belongs to only one group per read mode. Quotes and images belong to the activities, so use log() to show each one. Storage Architecture shows where groups are stored and how a feed retrieves them.
Choosing a Read Mode
In a longer feed, repeated actions and activities at busy places appear as rows you can expand:
Today
Planck’s constant is 6.62607004.


- a hot dog
- a corn dog
- a pretzel


What the machine needs
Alexei’s account: the machine is opening a gate beneath the mall.
- Locate the control room.
- Reach the two shutdown keys.
- Check the safe combination before going in.
Working note: translation is evidence, not a complete floor plan.
Yesterday
Find out where the elevator in the storeroom goes down to.
- Price
- $2.95
- Section
- Sundaes
- Available
- At the counter
- Orders
- 12
Tuesday
- Amount
- $497.00
- Billed to
- Starcourt Mall
- Reference
- INV-1985
- Price
- $2.95
- Section
- Sundaes
- Available
- At the counter
- Orders
- 12

- Price
- $2.95
- Section
- Sundaes
- Available
- At the counter
- Orders
- 12
The visitor guide includes entrances, parking and shop locations.
The counter opens at 10 am. Orders are available until 9 pm.
Monday

Match each line of the message to a place in the mall.
Every magnet on the store display fell off at once, then did it again an hour later.
Sunday
Pick out the mall in the sounds behind the message on the tape.
Work out what the Russian message on the tape is saying.
- Amount
- $520.75
- Billed to
- Starcourt Mall
- Reference
- INV-1984
They came back every night for my fertilizer, bag after bag
They came back every night for my fertilizer, bag after bag
- Price
- $2.95
- Section
- Sundaes
- Available
- At the counter
- Orders
- 12
- Price
- $2.95
- Section
- Sundaes
- Available
- At the counter
- Orders
- 12
Every magnet on the store display fell off at once, then did it again an hour later.
Every magnet on the store display fell off at once, then did it again an hour later.
Saturday
STATION: CEREBRO / WEATHERTOP
CALL TO UTAH .......... NO REPLY
UNEXPECTED SIGNAL .... VOICE / RUSSIAN
MESSAGE .............. REPEATING
NEXT STEP ............ KEEP THE TAPETape the Russian message Cerebro picked up on Weathertop.
- About
- The radio built at camp, to reach Utah from Weathertop
- Visibility
- Public
Friday
The power went out across town this evening. It came back on its own a minute later.
- About
- The radio built at camp, to reach Utah from Weathertop
- Visibility
- Public
Jun 26, 1985
- Amount
- $455.25
- Billed to
- Starcourt Mall
- Reference
- INV-1983
Jun 19, 1985
- Amount
- $388.00
- Billed to
- Starcourt Mall
- Reference
- INV-1982
Jun 12, 1985
- Amount
- $412.50
- Billed to
- Starcourt Mall
- Reference
- INV-1981
Jun 10, 1985
hawkins-post-internship-agreement.pdf · 60 KB · application/pdf
hawkins-post-internship-agreement.pdf · 60 KB · application/pdf
Jun 7, 1985
scoops-ahoy-employment-contract.pdf · 47 KB · application/pdf
scoops-ahoy-employment-contract.pdf · 47 KB · application/pdf
Dec 15, 1984
Nov 30, 1984
hess-farm-sale-agreement.pdf · 129 KB · application/pdf
Nov 3, 1984
10 REM DOOR OVERRIDE — ILLUSTRATIVE BASIC
20 INPUT "SECURITY CODE"; C$
30 IF C$ = "" THEN GOTO 20
40 PRINT "MANUAL OVERRIDE REQUESTED"
50 FOR D = 1 TO 4
60 PRINT "DOOR"; D; " RELEASE REQUEST SENT"
70 NEXT D
80 ENDNov 1, 1984
Oct 29, 1984
Oct 28, 1984
Dec 24, 1983
Nov 12, 1983
Nov 10, 1983
Nov 9, 1983
- A B C D E F G H
- I J K L M N O P Q
- R S T U V W X Y Z
Nov 7, 1983
The read mode determines which groups the query returns:
| Mode | Returns |
|---|---|
log() | one item per activity, including each member of a composite |
live() | one group per activity, chosen from the groups it qualifies for, with repeat as the fallback. The default |
summary() | one summary item per actor per calendar day (or the period passed to summary()), across verbs. See Retrieving Feeds |
Set grouping.curate to false to limit live() to repeats. storyfeed:curate chooses groups for recent activities and runs hourly through Laravel's scheduler.
Grouping Axes
Built-In Axes
| Axis | Collapses | Singular Tokens Allowed | One Type | Example Headline |
|---|---|---|---|---|
repeat | one actor repeating a verb | :actor :target | yes | ":actor placed :count orders with :target" |
actors | many actors, same verb and target | :target | no | ":actors ordered from :target" |
targets | one actor across targets | :actor | no | ":actor asked about :targets" |
object | many actions on one object | :actor :object | yes | ":actor changed the price of :object :count times" |
composite | an authored collection story | :actor :target :context | — | see Composites |
Headlines for groups marked One Type may go in a Story class or inside Story::for(). Define the others on the verb alone.
Configuring Grouping Thresholds
'grouping' => [
'policy' => [
'min_actors' => 3,
'min_targets' => 2,
'min_target_members' => 3,
'min_object_members' => 2,
],
],| Key | What the Axis Needs | Default |
|---|---|---|
min_actors | actors: this many different actors | 3 |
min_targets | targets: this many different targets | 2 |
min_target_members | targets: this many activities | 3 |
min_object_members | object: this many activities on the one object | 2 |
Activities below a threshold cannot form that group. They fall back to repeat when no other group qualifies. After changing thresholds, run php artisan storyfeed:curate to re-evaluate existing groups. Changes to an axis's grouping key or newly registered axes require rehashing.
See Grouping Periods to choose the calendar boundary shared by grouped activities.
Defining Group Headlines
Pass one headline per axis to grouped(), as in the repeat and actors examples. Where you declare it determines which groups use it.
Definition Scope
| Written In | Key | Used For |
|---|---|---|
Story::for(Order::class)->verb('place'), or a Story class's place() | repeat.order.place | groups of orders |
Story::verb('place') | repeat.place | groups of any type |
Storyfeed tries the key with the group's type first, then the key without it.
Choose the declaration location from the axis's shared values. The default grouping period is one day.
| Shared Values | Axis | Headline | Declared On |
|---|---|---|---|
| one actor, verb, target and object type, on one day | repeat | :actor made :count order placements with :target | the type |
| one verb and target on one day, from several actors | actors | :actors ordered from :target | the verb |
| one actor, verb and object, on one day | object | :actor changed the price of :object :count times | the type |
| one actor and verb on one day, across several targets | targets | :actor asked :count questions about :targets | the verb |
A type-level headline may name that type because every member shares it. A verb-level headline may describe several object types, so avoid naming a particular type. :count counts activities, not distinct objects; see the repeated-order example for wording that keeps this distinction visible.
Singular and Plural Tokens
| Singular Token | Plural Token | Entity Role |
|---|---|---|
:actor | :actors | who acted |
:object | :objects | what the activity acted on |
:target | :targets | what the activity was directed at |
:context | :contexts | the surrounding container |
:origin | :origins | the source |
:result | :results | the produced entity |
:instrument | :instruments | the tool or service used |
A plural token displays a few names and a count of the rest. See Rendering for details. :count is the number of activities in the group.
A singular token is allowed only when every activity shares that role, as shown in Singular Tokens Allowed above. Plural tokens are allowed in any group headline.
// a repeat group: one customer, many dishes
':actor changed the price of :object :count times' // ✗ which dish? fails when stories compile
':actor changed :count prices' // ✓
':actor changed :count prices on :targets' // ✓ lists fit every memberBoth headlines use allowed tokens, but three lists make the first hard to read:
// an actors group: every member shares :target
':actors placed :objects with :targets' // ✗ three lists of names
':actors ordered from :target' // ✓ one list, one shared roleUse one list per headline and replace the others with :count.
Missing Roles
A plural token lists only filled roles. The targets axis groups by actor, verb, and calendar period (a day by default). An activity with an empty target can therefore join the group: it contributes to :count but adds no name.
// a targets group of 5 members, 2 of them carrying a target
':actor asked about :count dishes' // ✗ five members, two dishes
':actor asked about :targets' // ✓ names the two there areThe first headline says there are five dishes when only two are recorded. Storyfeed does not check nouns beside :count.
Fallback Nouns
With no group headline, a group tries the single-activity headline. A role that differs across the group becomes a plain noun, such as "dishes", when all its entities are one type. Otherwise the group has no headline, and your renderer handles it.
Give a type its noun:
use App\Models\MenuItem;
use Storyfeed\Facades\Story;
Story::for(MenuItem::class)->fallback()->noun('dish|dishes');Supply both forms; Storyfeed does not derive plurals. For more plural forms, add pipe segments. See Localization for translated nouns. The default is item|items.
The entity count selects the form: FeedNoun::form('dish|dishes', 7) returns dishes. The headline :actor put :object on the menu becomes :actor put dishes on the menu. The noun is plain text; :actor remains a link.
Defining Custom Axes
Define a custom axis with the fields activities must share and the threshold they must meet:
use Storyfeed\Facades\Storyfeed;
use Storyfeed\Grouping\Axis;
Storyfeed::axes([
Axis::make('scene')
->key('v:ca!:cid!:d') // same verb, same context, same day
->eligibleWhenDistinct('actor', min: 2),
]);Here, scene groups activities in the same context, such as three customers asking about dishes in one shop. Use $group->axis('scene', …) inside grouped() to define its headline, or $group->any(…) to match any axis.
Axis Keys
Separate shared fields with :. Add ! after a field to exclude activities where it is empty.
| Role | Type Field | Id Field |
|---|---|---|
actor | aa | aid |
object | oa | oid |
target | ta | tid |
context | ca | cid |
origin | ora | orid |
result | ra | rid |
instrument | ia | iid |
Add v to group by verb and d to group by calendar period (a day by default). A singular token such as :context requires both of that role's fields in the key. Without v, the group may contain several verbs, so define its headline on a key without a verb (scene.* or *.*).
Prioritizing Axes
New axes have the lowest priority. To place one before a built-in axis:
use Storyfeed\Facades\Storyfeed;
Storyfeed::axes([$scene], before: 'repeat');



