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:
- Config map (the common case) — a model plus a
maparray inconfig/voicebot.php. - Custom source (the escape hatch) — implement
EntitySourcefor 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:
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:
| Key | Meaning |
|---|---|
enabled | Only true kinds are synced. Disable kinds you don't have. |
model | A class extending Illuminate\Database\Eloquent\Model. |
external_id | Raw id (column name or closure). The package wraps it to laravel:{kind}:{id}. |
updated_at | Column compared against the watermark to select changed rows for deltas. |
with | Relations to eager-load while streaming — prevents N+1 on closures that touch relations. |
map | The payload. Keys are dot paths under payload; values are a column name or a closure. |
source | Optional: 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:
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 unit — 199.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
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:
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:
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_of → laravel: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_ofkeys 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:
'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.
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:
EntityKind::Product->value => [
'enabled' => true,
'source' => \App\VoiceBot\ProductSource::class,
],
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.