Custom Body Types
Introduction
Use a custom component to render application-specific props, or define a versioned body type when you need to upgrade its stored payload over time.
Using Custom Components
A Component body names a frontend component and passes it props. For example, an order at Scoops Ahoy may show its pickup progress as a stepper. The built-in bodies can display text and fields; a custom component can connect the steps and highlight the current one.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Storyfeed\Body\Component;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;
use Storyfeed\FeedEntity;
class Order extends Model implements Feedable
{
use InteractsWithFeed;
public function toFeed(): FeedEntity
{
return FeedEntity::make()
->label("Order #{$this->id}")
->body(
Component::make()
->name('Orders/Progress')
->props([
'title' => "Order #{$this->id}",
'steps' => ['Placed', 'Confirmed', 'Ready'],
'current' => $this->status_label,
'pickup' => $this->pickup_at->format('g:i A'),
]),
);
}
}<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Storyfeed\Body\Component;
use Storyfeed\Concerns\InteractsWithFeed;
use Storyfeed\Contracts\Feedable;
use Storyfeed\FeedEntity;
class Order extends Model implements Feedable
{
use InteractsWithFeed;
public function toFeed(): FeedEntity
{
return FeedEntity::make(
label: "Order #{$this->id}",
body: Component::make(
name: 'Orders/Progress',
props: [
'title' => "Order #{$this->id}",
'steps' => ['Placed', 'Confirmed', 'Ready'],
'current' => $this->status_label,
'pickup' => $this->pickup_at->format('g:i A'),
],
),
);
}
}The props are plain values: a title, a list of steps, the current step's label, and a formatted pickup time. They are stored with the body and reflect the values when the body was built. To display current progress whenever the feed is retrieved, build the body in feedMedia().
Rendering the Component
Your frontend maps each name to a component. In Vue, the component receives the stored props and marks the current step with aria-current:
<script setup>
defineProps(['title', 'steps', 'current', 'pickup'])
</script>
<template>
<section class="order-progress" :aria-label="`${title} pickup progress`">
<strong>{{ title }}</strong>
<p>Pickup at {{ pickup }}</p>
<ol>
<li v-for="step in steps" :key="step"
:aria-current="step === current ? 'step' : undefined">
{{ step }}
</li>
</ol>
</section>
</template>In your body renderer, register the component under the same name used in PHP and pass it the body's props:
<script setup>
import Progress from '../orders/Progress.vue'
defineProps(['body'])
const components = { 'Orders/Progress': Progress }
</script>
<template>
<component v-if="components[body.name]"
:is="components[body.name]" v-bind="body.props" />
</template>Use this renderer for bodies whose $body is Storyfeed/Body/Component. Style the list as a stepper, with [aria-current="step"] highlighting the current step. The following item uses that mapping and a styled component:
Pickup at 12:15 PM
- Placed
- Confirmed
- Ready
Names are stored unchanged. Like data(), props() merges an array of keys or sets one with ->props('current', 'Ready').
Use Component for props you control. If their structure will change over time, define a body type with its own upgrade() method.
Writing Body Types
<?php
namespace App\Feed;
use Storyfeed\Concerns\HasPayload;
use Storyfeed\Contracts\FeedBody;
final class Attachment implements FeedBody
{
use HasPayload;
private function __construct(
private readonly ?int $size,
private readonly ?string $mediaType,
) {}
public static function make(?int $size = null, ?string $mediaType = null): self
{
return new self($size, $mediaType);
}
public static function bodyType(): string
{
return 'Acme/Attachment';
}
public static function version(): int
{
return 1;
}
public static function upgrade(array $payload, int $from): array
{
// Missing or unrecognised values become null.
return [
'size' => is_int($payload['size'] ?? null) ? $payload['size'] : null,
'mediaType' => is_string($payload['mediaType'] ?? null)
? $payload['mediaType']
: null,
];
}
public function toPayload(): array
{
// Values only: no markup, and never another body.
return [
self::KEY => self::bodyType(),
self::VERSION => self::version(),
'size' => $this->size,
'mediaType' => $this->mediaType,
];
}
}HasPayload builds toArray() from toPayload().
Type Names
Return a PascalCase Vocabulary/Type name from bodyType(), such as Storyfeed/Body/MediaObject or Acme/Attachment. Renderers match it exactly. Stored bodies keep this name even if you move the PHP class.
Reserved Keys
| Key | Constant | Holds |
|---|---|---|
$body | FeedBody::KEY | the body type's name, verbatim |
$v | FeedBody::VERSION | the version that wrote the body |
The $ prefix keeps them apart from your own keys.
Versions and Upgrades
Start version() at 1. Call the body's upgrade() method to convert older payloads for your frontend. Storyfeed preserves the stored body and version.
Bodies arrive as stored, including $v, so your renderer must call the body type's upgrade() method before displaying versions it supports.
Storyfeed is headless: it has no views
Storyfeed serializes the feed as a structured payload, and your frontend chooses how to render it. For Blade, Storyfeed UI renders it with one component.
For built-in bodies and ordinary attachment, see Activity Content.
See Adding Multiple Bodies for appending bodies to an entity in any role.
See Using Current Values to build a body when the feed is retrieved.
See Choosing Stored or Current Values for publication-time data, snapshots, and resolved bodies.
See Deferring Body Construction to build a body only when the payload needs it.
See Accessing Resolver Data for snapshots and batched model loading.