Skip to main content
Version: v1

Mapping your models

The package never assumes your schema. For each entity kind you tell it which model to read and how its columns map to VoiceBot's canonical payload. There are two levels:

  1. Config map (the common case) — a model plus a map array in config/voicebot.php.
  2. Custom source (the escape hatch) — implement EntitySource for full control.

Both produce the same canonical records; pick per kind.

The config-driven Eloquent map

Each enabled kind in config/voicebot.php is a small block:

config/voicebot.php
use App\Models\Product;
use Monoverse\VoicebotSync\Dto\EntityKind;

'entities' => [
EntityKind::Product->value => [
'enabled' => true,
'model' => Product::class,
'updated_at' => 'updated_at', // watermark column for delta selection
'external_id' => 'id', // wrapped to "laravel:product:{id}" automatically
'with' => ['categories'], // eager-load to avoid N+1 while streaming
'map' => [
// payload.<key> => column name | closure receiving the model
'payload.name' => 'title',
'payload.sku' => 'sku',
],
],
],

How the block is read:

KeyMeaning
enabledOnly true kinds are synced. Disable kinds you don't have.
modelA class extending Illuminate\Database\Eloquent\Model.
external_idRaw id (column name or closure). The package wraps it to laravel:{kind}:{id}.
updated_atColumn compared against the watermark to select changed rows for deltas.
withRelations to eager-load while streaming — prevents N+1 on closures that touch relations.
mapThe payload. Keys are dot paths under payload; values are a column name or a closure.
sourceOptional: a class implementing EntitySource that replaces the whole config block.

Map values: column or closure

  • A string value is resolved against the model with Laravel's data_get — a column name or a dot path into a relation (e.g. 'brand.name').
  • A closure receives the model and returns the value — use it for any transform.
'map' => [
'payload.name' => 'title', // column
'payload.brand' => 'brand.name', // relation dot-path
'payload.description' => fn ($m) => trim(strip_tags((string) $m->description)), // closure
],

Keys starting with payload. are nested under payload; the payload. prefix is stripped for you.

Presets — the fast path

For the common kinds, Presets turns a few column names into the full canonical map (money → integer minor units, content → plain text, taxonomy → slug lists, stock → status) so you don't hand-write the closures:

config/voicebot.php
use Monoverse\VoicebotSync\Mapping\Presets;

EntityKind::Product->value => [
'enabled' => true,
'model' => App\Models\Product::class,
'with' => ['categories', 'tags'],
'map' => Presets::product([
'name' => 'title', 'sku' => 'sku', 'price' => 'price',
'description' => 'body', 'stock' => 'in_stock',
'categories' => 'categories', 'permalink' => 'url',
]),
],

Presets::product([...], $currency), Presets::category([...]) and Presets::page([...]) take the canonical key → your column (or relation / dot-path). Extend or override by merging your own closures into the returned array (Presets::product([...]) + ['payload.specs' => fn ($m) => …]). For anything they don't cover, write the map by hand as below.

The mapping rules that matter

These four trip everyone up. Get them right and voicebot:doctor passes.

1. Money is integer minor units

Prices on the wire are integers in the currency's minor unit199.99 UAH is 19999, never a float or a decimal string. Convert with a closure:

'payload.price_amount' => fn ($m) => (int) round($m->price * 100),
'payload.regular_price_amount' => fn ($m) => (int) round($m->regular_price * 100),
'payload.currency' => fn () => config('voicebot.currency', 'UAH'), // ISO 4217
Never send a float for money

Sending 199.99 (a float) instead of 19999 (minor units) is the single most common mapping mistake. (int) round($value * 100) is the canonical conversion.

2. Categories and tags are slug lists

Reference taxonomy by slug strings, not objects or ids:

'payload.categories' => fn ($m) => $m->categories->pluck('slug')->all(), // ['kava', 'napoi']
'payload.tags' => fn ($m) => $m->tags->pluck('slug')->all(),

(Eager-load these via 'with' => ['categories', 'tags'] so the closure doesn't trigger N+1.)

3. Content is plain text

Page / post content (content_text) is plain text for retrieval — strip HTML:

'payload.content_text' => fn ($m) => trim(strip_tags((string) $m->body)),

4. Hierarchy uses parent_external_id

Parent references follow the same wrapped shape as external_id — build them explicitly. Return null for roots:

'payload.parent_external_id' => fn ($m) =>
$m->parent_id ? 'laravel:category:'.$m->parent_id : null,

The same applies to menu_item (menu_external_id, parent_external_id) and standalone variation (parent_external_id).

Soft deletes

If a model uses the SoftDeletes trait, the package detects it automatically and emits delete ops for trashed rows during deltas — no configuration. Hard deletes (rows removed outright) can't be detected from a delta; they are reconciled by the nightly full snapshot's server-side tombstone pass. This is the main reason to schedule a nightly --full (see Scheduling).

A worked example: App\Models\Product

A typical product model with a decimal price, a stock flag, and category/tag relations:

config/voicebot.php — product map
use App\Models\Product;
use Monoverse\VoicebotSync\Dto\EntityKind;

EntityKind::Product->value => [
'enabled' => true,
'model' => Product::class,
'updated_at' => 'updated_at',
'external_id' => 'id',
'with' => ['categories', 'tags'],
'map' => [
'payload.name' => 'title',
'payload.sku' => 'sku',
'payload.slug' => 'slug',
'payload.price_amount' => fn ($m) => (int) round($m->price * 100),
'payload.currency' => fn () => config('voicebot.currency', 'UAH'),
'payload.description' => fn ($m) => trim(strip_tags((string) $m->description)),
'payload.stock_status' => fn ($m) => $m->in_stock ? 'instock' : 'outofstock',
'payload.permalink' => fn ($m) => route('product.show', $m),
'payload.categories' => fn ($m) => $m->categories->pluck('slug')->all(),
'payload.tags' => fn ($m) => $m->tags->pluck('slug')->all(),
],
],

Run php artisan voicebot:doctor and confirm the entity:product line reads sample maps cleanly with name and price_amount populated. See Data reference for the required field per kind and the full list of canonical fields the bot reads.

Translations (optional)

If a model row represents a single-language record, you can tag it with the canonical lang and link translations together. These are config keys alongside map (not under payload):

EntityKind::Page->value => [
// …
'lang' => 'locale', // column or closure → e.g. "uk"
'translation_of' => fn ($m) => $m->source_id ? 'laravel:page:'.$m->source_id : null,
],

Both are optional — omit them for a single-language store. See External IDs for how translation_of links a locale row to its canonical base, and how the backend canonicalizes per-locale ids before dispatching host actions.

Multiple locales in one row (fan-out)

If a single model row holds several languages (name, name_ru, name_en columns, or a translations relation), the config map fans it out for you — a base entity plus one per extra locale, each linked back with translation_of. No custom EntitySource needed:

config/voicebot.php — product map
EntityKind::Product->value => [
'enabled' => true,
'model' => Product::class,
'external_id' => 'id',
'translations' => [
'base_locale' => 'uk',
'locales' => ['uk', 'ru', 'en'],
// optional — skip a locale a given row doesn't have
'present' => fn ($m, string $locale) => $locale === 'uk' || filled($m->{'name_'.$locale}),
],
'map' => [
// map closures receive ($model, $locale) — return the value for that locale
'payload.name' => fn ($m, $locale) => $locale === 'uk' ? $m->title : $m->{'name_'.$locale},
'payload.price_amount' => fn ($m) => (int) round($m->price * 100),
],
],

For one row this emits laravel:product:84 (lang uk), laravel:product:84:ru (lang ru, translation_oflaravel:product:84) and laravel:product:84:en. The bot resolves a per-locale id back to the canonical base before acting — see External IDs.

Already storing one row per language? Don't set translations — tag each row with the single lang

  • translation_of keys shown above instead.

Variants from a relation

A configurable product's variant_axes and inline variations[] can be built from a relation — no custom source. Point variations at the variant rows and declare the axes:

config/voicebot.php — product map
'variations' => [
'items' => fn ($p) => $p->variants, // relation/closure → variant models
'external_id' => fn ($v) => 'laravel:variation:'.$v->id,
'axes' => [
// axis slug => { name (locale-aware label), value (per-variant label), value_external_id? }
'color' => [
'name' => fn ($locale) => $locale === 'en' ? 'Color' : 'Колір',
'value' => fn ($v, $locale) => $v->{'color_'.$locale} ?? $v->color_uk,
'value_external_id' => fn ($v) => 'laravel:option:color:'.ltrim((string) $v->color_hex, '#'),
],
'size' => ['name' => fn () => 'Size', 'value' => 'size'],
],
'fields' => [ // extra per-variation fields beyond external_id + attributes
'price_amount' => fn ($v) => (int) round($v->price * 100), // inline variations use minor units
'stock_status' => fn ($v) => $v->stock_qty > 0 ? 'instock' : 'outofstock',
],
],

This emits payload.variant_axes ([{ name, slug, values: [{ label, external_id? }] }]) and payload.variations ([{ external_id, attributes: { "<axis name>": "<value>" }, ...fields }]). The axis name is the key the bot uses in select_variant, so it must match between variant_axes and each variation's attributes — declaring it once here guarantees that. The axis name closure receives ($locale); value / value_external_id receive ($variant, $locale). Eager-load the relation ('with' => ['variants']) to avoid N+1.

Custom source: full control

When the config map can't express your data (computed catalogs, joins across services, a non-Eloquent store, bespoke delete detection), implement the EntitySource contract and point the kind's source at your class. It is resolved from the container.

app/VoiceBot/ProductSource.php
use Carbon\CarbonInterface;
use Illuminate\Support\LazyCollection;
use Monoverse\VoicebotSync\Contracts\EntitySource;
use Monoverse\VoicebotSync\Dto\CanonicalEntity;
use Monoverse\VoicebotSync\Dto\EntityKind;

final class ProductSource implements EntitySource
{
public function kind(): EntityKind
{
return EntityKind::Product;
}

public function upserts(?CarbonInterface $since): LazyCollection
{
// $since === null → full snapshot (ALL rows); otherwise rows changed strictly after $since.
// MUST stream — never materialise the whole catalog.
return Product::query()
->when($since, fn ($q) => $q->where('updated_at', '>', $since))
->lazyById(200)
->map(fn ($p) => new CanonicalEntity(
EntityKind::Product,
'laravel:product:'.$p->id,
[
'name' => $p->title,
'price_amount' => (int) round($p->price * 100),
'currency' => 'UAH',
],
));
}

public function deletes(?CarbonInterface $since): LazyCollection
{
// Return delete ops (or LazyCollection::empty()). The nightly full snapshot
// reconciles hard deletes via server-side tombstones if you can't detect them.
return LazyCollection::empty();
}

public function expectedCount(): int
{
return Product::query()->count(); // arms the server tombstone guard on full snapshots
}

public function updatedAtColumn(): string
{
return 'updated_at';
}
}

Wire it in config/voicebot.php:

config/voicebot.php — custom source
EntityKind::Product->value => [
'enabled' => true,
'source' => \App\VoiceBot\ProductSource::class,
],
Stream, never materialise

upserts() and deletes() must return a streaming LazyCollection (lazyById / cursor). The whole point of the sync is that the full catalog never lands in memory — returning a materialised collection defeats it and can OOM on large catalogs.

A custom source replaces the entire config block for that kind: model, map, with, updated_at, external_id are ignored. You own the canonical shape, the delta query, and the count.