glue-agency/craft-influx

Connects Craft elements to external JSON APIs. Links are stored in Project Config.

Maintainers

Package info

github.com/glue-agency/craft-influx

Documentation

Type:craft-plugin

pkg:composer/glue-agency/craft-influx

Transparency log

Statistics

Installs: 93

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0-rc.1 2026-08-13 12:14 UTC

README

Connect Craft elements to external JSON APIs. A lighter, Project-Config-backed alternative to FeedMe that hydrates existing element types (every native Craft one — entries, assets, categories, tags, users and globals — and any other through a target adapter) instead of owning its own element type.

Why another sync plugin

FeedMe carries a lot of historical surface area (XML, CSV, complex UIs, project-config quirks). Influx makes a few opinionated cuts:

  • Project Config is the source of truth. Links live under influx.links.{uid} and round-trip to YAML the same way sections, entry types, and volumes do — full diff, full deploy story, full allowAdminChanges gating.
  • JSON only. One transport, one parser.
  • Hydrates, doesn't own. Influx writes to whatever element type you point it at. Hooking it to Solspace Calendar's Event is a target adapter, not a fork.
  • Change-detection before save. Each mapping reports whether it would change anything; unchanged elements skip the save entirely.
  • .env-backed auth headers resolved at fetch-time.
  • Per-language endpoints in a single link. One link can fan out across Craft sites and write localized values onto the same canonical element.

Requirements

  • Craft CMS 4.0+ or 5.0+
  • PHP 8.1+

Installation

composer require glue-agency/craft-influx
./craft plugin/install influx

Quick start

  1. Open Influx → Links in the CP and click New link (requires allowAdminChanges).
  2. Fill in the form. The shape mirrors Craft's own Sections / Entry Types editors.
  3. Save. The link is written to Project Config; commit the resulting YAML in config/project/.
  4. Trigger a sync from the Links overview, the entry edit page, or the CLI:
./craft influx/sync news                # one link by handle
./craft influx/sync news,events         # several, comma-separated
./craft influx/sync --all               # everything
./craft influx/sync news --offset=hour  # use the "hour" offset preset from the link config
./craft influx/sync news --site=fr      # only the "fr" per-site endpoint

Runs also trigger from the CP — the sync action on a link's row under Influx → Links, or the "Sync from remote" action on a synced entry, offered to users holding the Sync elements from a remote link permission. Link-level CP runs are queued (one job per site, one feed page per step, so large feeds don't time out a request); single-element runs and console runs are synchronous. Unless logging is switched off in the plugin settings, every run produces a log under Influx → Logs with a per-item drill-down. The Debug screen, reachable from a link's row, dry-runs the feed against the current mapping without writing anything — for building/troubleshooting a link before it goes live.

On the edit screen of any element a link writes to, each actively-mapped field carries a small Influx mark next to its label, tooltipped "This field is updated by Influx." — so an editor sees at a glance which values the next sync may overwrite. Purely informational, and not permission-gated.

Migrating from Feed Me

Existing Feed Me feeds can be converted to Influx links. The command reads the feedme_feeds table directly, so Feed Me doesn't need to be enabled — just installed at some point:

./craft influx/feed-me            # list available feeds
./craft influx/feed-me 1,3        # import specific feeds
./craft influx/feed-me --all      # import everything
./craft influx/feed-me 1 --dry-run  # preview the link config without saving
./craft influx/feed-me 1 --force    # save even when the link doesn't validate

The conversion is best-effort: everything that can't be carried over (parent entries, non-JSON feed types, ...) is reported as a warning so you can finish the link in the builder. Matrix block mappings do convert, but only their custom fields — Feed Me's per-block-type keys other than fields are warned about and dropped.

A Matrix builds its blocks from one list, so a converted Matrix reads which node that is out of Feed Me's own paths — the list node moves onto the Matrix row and the child paths are rebased onto a list item, which is also what preserves Feed Me's block order (it walks the feed and sorts on the array index in each node path). Two shapes can't be read and leave the row without a list node, with a warning, for you to finish in the builder: block types mapped out of different feed nodes, and several block types sitting flat under one list — the case Feed Me itself resolves by attributing a shared child handle to whichever type was configured first. An unfinished row is never addressed, so until you finish it the field is left alone rather than cleared.

Feeds saved by Feed Me 4, 5 and 6 all convert — the stored shape is identical across those majors bar two divergences the importer accepts interchangeably: the entry-author handle (authorId through v5, authorIds since v6) and relation options.match values (raw content-table column names through v5, bare handles since v6). Feed Me never rewrites stored fieldMapping JSON on upgrade, so the vintage is a property of the row rather than of the installed version — which is why there's one converter instead of one per major.

Concepts

Registries

Three things are pluggable — element targets, mapping field strategies and auth strategies — and all three work the same way. Write a class implementing the extension point's contract, then hand it to the registry either by listening to its registration event (from your plugin's init()):

use GlueAgency\Influx\services\TargetsService;
use yii\base\Event;

Event::on(
    TargetsService::class,
    TargetsService::EVENT_REGISTER_TARGETS,
    fn($event) => $event->targets[] = MyCalendarTarget::class,
);

…or imperatively:

use GlueAgency\Influx\Influx;

Influx::getInstance()->targets->register(MyCalendarTarget::class);

The event payload arrives pre-seeded with the built-ins, so a listener can append to the list, replace a built-in (register a class declaring the same key — element type / Craft field class / auth type) or remove one by filtering the array. Registration resolves lazily, once, on first use.

Each registry hands out one shared prototype instance per registered class, built through Craft::createObject() — so your class may declare constructor dependencies the container can resolve. Influx::getInstance()->targets->all() (likewise ->fields->all(), ->auth->all()) returns them keyed by their declared key.

Targets

A target is an adapter for one element type. The plugin ships one per native Craft element type, plus one for the third-party element types it supports out of the box:

Target Element type Scopes on Notes
EntryTarget craft\elements\Entry section + entry type title / slug / enabled / postDate / expiryDate / author
AssetTarget craft\elements\Asset volume the file arrives through a native File URL row; filename / title / alt follow the volume's layout
CategoryTarget craft\elements\Category category group title / slug / enabled / parent, so a feed can build the tree
TagTarget craft\elements\Tag tag group title / enabled — Craft derives a tag's slug, so no row for it
UserTarget craft\elements\User names / email / enabled / newPassword, plus groups, photo, suspension and activation
GlobalSetTarget craft\elements\GlobalSet global set update-only — a global set is declared in project config, never created by a feed
EventTarget Solspace\Calendar\Elements\Event calendar Solspace Calendar. title (when the calendar has a Title field) / slug / enabled / startDate / endDate / allDay / postDate / author. Recurrence is out of scope: it's one interdependent rule over nine columns, not a value a single mapping row can own

Third-party plugins register their own through TargetsService::EVENT_REGISTER_TARGETS or ->targets->register() (see Registries); targets are keyed by the elementType() they declare.

A target implements ElementTargetInterface: find existing element by match value, build a fresh one (with all the type-specific required attributes set), and own every write to it — save() plus disable / disable-for-site / delete / delete-for-site. Every write to the synced element routes through the target instead of Craft's element API, so a target can save with whatever flags its element type needs; the base implementation is Craft's own save with validation on (see AbstractElementTarget::save() for the trade). Related elements a mapping creates on the fly are the strategies' own business, not the target's.

Six static capabilities let a target describe its element type to the builder and the sync engine, plus one per-link capability that can't be static:

  • isAvailable() — whether the element type is installed at all. That's what lets a target for a third-party element type ship in the box: an unavailable one is dropped from the built-in set before registration, so nothing downstream knows the concept exists. The base answers from the element class alone (is_subclass_of()), so a target usually declares nothing; EventTarget narrows it to also require Calendar's plugin, since the package can be in the vendor tree with the plugin uninstalled. Field strategies need no such gate — a strategy is filed under a class string only ever reached by looking a real field's class up against it, whereas the builder iterates the targets and asks each one for a criteria dropdown built from its own plugin's services.
  • supportsMultiSite() — whether links can carry per-site endpoints and be swept per-site. Localizable types (Entry, Asset, Category, Tag, GlobalSet) return true; global, non-localizable ones (User) return false, so their links always run once against a single endpoint and the CP hides the Per-site Link endpoint controls. Link rejects site endpoints configured against a non-multi-site target as a server-side backstop.
  • criteriaKeys() — the elementCriteria keys the type scopes on (Entry uses ['section', 'type']; User has none). The target owns those key names as constants (EntryTarget::CRITERIA_SECTION), and stored criteria are read through Link::criterion($key).
  • criteriaSchema() — the dropdowns that fill those keys in, as a SchemaBuilder schema the builder's General tab renders directly. Two node keys shape a cascade: dependsOn names the handle a node's list is keyed on, and optionsBy is that list per parent value — how Entry narrows its entry-type dropdown to the picked section. A new element type therefore needs no CP change at all.
  • supportsCreating() — whether a feed may create elements of this type at all. GlobalSetTarget returns false; the builder drops the create policy for it, a save drops it from stored config with a notice, and buildNew() throwing is the last line of defence.
  • supportsSweeping() — whether links to this type can be swept for elements missing from the feed. A sweep acts on the complement of what a run saw, so it needs a target that can enumerate "everything this link owns" (missingElementsQuery()). Types with no scoping dimension (User: the candidate set would be every user in the system) return false, and the builder then leaves the disable-/delete-missing policies out of the processing checkboxes; a stored policy from before that gets a reported skip in the run's log rather than silently doing nothing.
  • requiresMatch(Link $link) — whether a link identifies its elements by a match value at all. false when the criteria already name ONE element: a Global Set, or an Entry link whose section is a Craft Single. Such a link has no Match key in the builder, no match validation, and resolves through findWithoutMatch() instead of findByMatchValue(); a stored match key is dropped on save with a notice. This one is per-link rather than static, because for entries the answer depends on the link's own section — so it travels with the mappable-fields response the Mapping tab already refetches when the criteria change, not with the static flags above. A target that can't resolve its criteria answers true: "can't tell" must not quietly relax the requirement. Since every item in such a feed resolves to the same element, the first item wins and the rest are reported as skipped rather than overwriting it.

Two per-link members round it out. claimCells() reports the comparable cells two links intersect on when Influx warns that both define a resource mapping for the same elements (entries expand to "{section} {entryType}", the group- and volume-scoped types report their group handle; the base reports one * sentinel, so two links of an unpartitioned type always overlap). criteriaLabel() is how the Links overview reads a link's scope back — "Movies / Feature", a volume or a group name.

A target also reports which fields a link may map to: getMappableFields() returns a list<MappableField> — its element type's native attributes, declared with the same SchemaBuilder the mapping extras use, plus the custom fields on the layout fieldLayout() resolves, grouped the way the element editor groups them. Natives the element type hides are left out by omission: an entry type with hasTitleField off never offers title, and a volume without the Alt layout element never offers alt. A stored mapping for a handle the target no longer offers is pruned the next time the link is saved.

Some element state can't be written by an element save at all. A target claims those handles with ownsAttribute() — the applier then leaves them alone, and the log drill-down marks the row "not managed by element" — and reconciles them in afterCommit(), which runs with both the feed row and an element that has an id. That's how UserTarget handles group membership, the user photo, suspension and activation. GlobalSetTarget claims handle for the opposite reason: nothing reconciles it, and nothing may write it, because a global set's handle is project config. It offers no handle row any more, so the claim only ever neutralises a mapping saved before the row was removed — stored config a save can't always reach (pruneMappings() never runs on project-config/apply, and bails on a layout with no custom fields), which without the claim would assign $element->handle from the feed.

Mappings

A mapping reads one field worth of data off a remote item and applies it to an element field. Each mapping declares whether its incoming value would change the element (hasChanged()) — that's how Influx decides to skip the save when nothing's different.

Built-in strategies, keyed by Craft field class and registered via FieldsService::EVENT_REGISTER_FIELDS:

  • Lightswitch, Date, Dropdown (covers option fields generally, e.g. Radio Buttons, Checkboxes) — truthy/falsy coercion, configurable date-format parsing, match-by-label-or-value.
  • Entries, Categories, Tags, Users — relation fields with a match-by strategy, with optional create-on-the-fly when nothing matches. What a flavour offers to match on is its own element type's identifiers (an entry's id/title/slug/uri, a user's id/username/email, a tag's id/title) plus every custom-field handle on its sources.
  • Assets — matches by id or by URL/filename, with best-effort fallback when a CDN host changes, and optional download-on-import when nothing matches. Sub-fields write back onto the matched asset.
  • RichText — CKEditor/Redactor-style fields.
  • Matrix — builds blocks from ONE remote list, one child-mapping tree per block type, one block per list item in the feed's own order. Sub-field paths are relative to a list item (image), and a Data type setting says how an item names its block type: by its own key ({"text": {…}}, the default), by a discriminator node ({"type": "text", …}, what most headless sources emit), or not at all (one block type for the whole list). The first two match a per-type feed key defaulting to the Craft handle, so a source spelling types its own way — Storyblok's component, Sanity's _type — takes an alias rather than a renamed block type. An item naming no mapped type is skipped; an empty list clears the field. Reading per item is what keeps blocks of the same type aligned, too: a path read collapses a list and drops nulls, so anything reading sub-fields as parallel paths would shift a value missing from one block onto the next. Every sync fully replaces the field's blocks from the feed (no per-block merge yet).
  • Table — one sub-mapping per column (keyed by column id, so a handle rename can't orphan it), values zipped by index into rows. Full-replace, like Matrix; change detection normalizes per column type so a checkbox or date column doesn't churn, and the inspectors drill into the result a row at a time (a table row is no element, so it's labelled by its position).
  • ContentBlock (Craft 5) — one sub-mapping per field in the block's layout. Between Matrix and Table: one implicit record, no block types, no fan-out. The strategy wraps the values in the {fields: …} envelope Craft's own field consumes, so the feed never has to carry Craft's serialization shape, and change detection compares the nested element's field values leaf by leaf.
  • Addresses (Craft 5) — a fan-out of nested Address elements over the one shared Address layout: many records, one implicit type. One sub-mapping per slot — the 17 native address properties plus the layout's custom fields, each landing in the channel Craft reads it from — index-zipped into as many addresses as the feed carries. Full-replace, like Matrix and Table. An empty countryCode is skipped rather than written, so Craft's own defaultCountryCode fallback still applies.
  • Link (Craft 5.3, and the deprecated URL field it aliases) — one sub-mapping per slot: the link type, its value, the label when the field shows one, and whichever advanced attributes the field enables. Assembles the array envelope Craft consumes; a type the field doesn't allow fails that row rather than the item. Element-typed links (entry, asset, category) take an element ID — no match-by lookup yet.
  • Time, Money, Color, Country, Icon, Json, Range — single-value fields whose stored form isn't what the feed ships, so the fallback's raw write churned or corrupted. Each parses the feed's spelling into what Craft stores and reduces both sides of the comparison to one canonical form: a clock time, a minor-unit amount (with an explicit units option, since Craft otherwise infers major-vs-minor from punctuation), a canonical hex, an alpha-2 code (matchable by name), a bare icon name, a key-sorted document (decoded first — Craft's programmatic normalize doesn't), and a value clamped and snapped to the slider's own scale.

DefaultField catches everything no strategy claims — Plain Text, Number, Email, and any Craft field type without a dedicated strategy: a direct setFieldValue(). It declares no Craft field class, so it isn't a registered strategy; the registry holds it apart as the fallback, and it never shows up in ->fields->all().

Add more by extending GlueAgency\Influx\fields\Field, declaring the Craft field class it handles via craftFieldClass() (a base class such as BaseOptionsField covers a whole family — lookups walk the parent chain), and registering it as any other extension point (see Registries).

The source-node dropdowns list what the fetched sample discovered — and the sample is one page. A key that only shows up on a later page can be mapped anyway: type it into the node search and pick the "Custom node" row the picker offers for a path it doesn't know. It saves like any other node and reads as a missing mapping until a page carrying it is fetched; at sync time it resolves per item, so items that do carry it get the value.

The builder's details sidebar reports where the sample stands and how much of the tree is mapped, and its Auto-match maps every field whose handle matches a node in the sample. It only ever fills a field that has no source node and no "use default" — a mapping you made is never overwritten — and the rows it filled carry an "auto" badge until you touch them. Nothing about that badge is stored: an auto-matched mapping is an ordinary one.

A relation or asset mapping gets one sub-field card holding both what the related element writes natively (an entry's title and slug, an asset's alt and title where the volume's layout includes them) and the custom fields of the layouts its sources allow. The two halves reach the element differently — an attribute is assigned, a custom field goes through the layout — so a row declares which channel it lands in; to the editor it's one list. Each row's default-value editor is the one its own field's strategy declares, so a relation sub-field offers an element picker rather than a text box.

A strategy's whole mapping row is declarative, and one declaration covers all of it. schema() returns a MappingSchema of three regions — the source-node cell, the default-value cell and the extras — and the CP renders every one of them through the same type => component map, so no Vue change is needed to add a control:

public function schema(CraftFieldInterface $field): MappingSchema
{
    return MappingSchemaBuilder::make()->mapping([
        'source'  => true,                                                  // the standard node select
        'default' => fn (MappingSchemaBuilder $b) => $b->defaultSelect([…]), // any control, any config
        'extra'   => fn (MappingSchemaBuilder $b) => $b->matchBy([…]),
    ]);
}

true is the region's preset, and an ABSENT region is an absent cell — which is the whole vocabulary for "this field has no default to pick" (a Matrix declares a source cell for the list its blocks come from, but no default — a set of blocks isn't a value to type into a box) and for "this field can't be mapped at all" (a Preparse field declares a source region holding nothing but a note()). There is no flag beside the regions saying either thing.

For a node type the builder doesn't ship, SchemaBuilder::node('myType', [...]) passes it through; the CP renders an unrecognised type as a labeled text input on the node's handle rather than dropping it. To render it properly instead, add a component and one line to builder/schema/registry.js.

Match

Every link needs a match config: attribute is the field/handle on the element used as a stable key (typically a custom plain-text field called importId). There's no separate match-source path — the match value is always read from that same field's own mapping node, so the field that identifies an item is mapped like any other field. Influx looks up the existing element by this attribute — unscoped across sites for a single-endpoint link, scoped to the site being processed for a per-site run. Either way a multi-language link converges on one canonical element, provided the match field isn't translatable so every site's row carries the same key.

Multi-site

Set per-site endpoints and the link runs once per site — one queue job and one log per site, in the configured order. Each pass matches the element inside the site it's processing and writes to that site's localized row, so every endpoint feeds the same canonical element. Element types whose target reports no multi-site support (Users) always run once against a single endpoint.

Concurrency

Can a per-site fan-out create the same element twice? No — double writes to the same site group are protected by a mutex lock on the jobs (influx:sync:{handle}), keyed on the link handle rather than the site, so one site's pass always finishes before the next starts looking.

Can two different links? Yes — the lock doesn't span links. Run links that write the same section in one sequential influx/sync a,b,c invocation.

Does it guarantee one element per match value? No. It serialises runs; finding the element from the next site's pass still depends on the section's propagation, which is Craft's setting, not the plugin's.

Partial import

A link can declare named presets — offset in config, Partial import in the CP — so a scheduled --offset=hour run only asks the feed for what changed recently, instead of re-fetching everything every time:

offset:
  hour:
    queryParam: modified_since
    value: "{{ now|date_modify('-1 hour')|date('c', 'UTC') }}"

Both keys are required. The value is Twig, rendered on every run with now available, and whatever it renders to is sent as ?modified_since=… (Guzzle URL-encodes it — don't pre-encode).

Writing the value rather than declaring a date format is deliberate: only you know which timezone the API on the other end compares against. date('c') alone formats in Craft's system timezone, so a site on Europe/Brussels sends 2026-08-10T14:58:45+02:00 — the right instant, spelled in local wall-clock. That is valid ISO 8601, and an API that reads it as wall-clock against a UTC column (Laravel binds a DateTimeInterface by calling format('Y-m-d H:i:s') on it, in the value's own timezone) sees a cutoff two hours in the future and returns nothing, run after run, reporting success as it goes. So say what you mean:

You want Value
UTC ISO 8601 {{ now|date_modify('-1 hour')|date('c', 'UTC') }}
Local ISO 8601 {{ now|date_modify('-1 hour')|date('c') }}
Unix timestamp {{ now|date_modify('-1 hour')|date('U') }}
Plain date {{ now|date_modify('-1 day')|date('Y-m-d') }}
A duration the API resolves itself 20 minutes

That last row isn't a special case — a value with no Twig tags renders to itself, so any literal the API expects can be typed straight in.

Prefer the date_modify filter over calling now.modify(): the filter works on a copy, the method mutates the now global in place.

Two things a preset doesn't do. It never sweeps for missing elements — an offset run's seen-set only covers the window, so the complement isn't missing, just outside it. And it never fails quietly: a preset that doesn't exist, is half-written, or won't render fails that run's log with the reason, rather than dropping the parameter and fetching the whole feed as if nothing happened.

Leave the window some slack. Nothing is watermarked — the value is recomputed from now on every run — so a run that is skipped, delayed, or fails takes its window's changes with it. Look back further than the interval you run on: a 15-minute schedule wants an hour's window, not fifteen minutes'.

Backup

A link can be flagged to take a full database backup (through Craft's own database-backup API, Craft::$app->getDb()->backup()) immediately before it runs — cheap insurance for a first sync or a link with delete permissions enabled. A failed backup aborts the run rather than letting a destructive sweep proceed unprotected.

Auth

Built-in strategies: Basic, Bearer, Custom Header, Query String. Tokens are stored exactly as written and resolved through Craft's App::parseEnv() at request time, so writing $API_KEY keeps the secret itself in .env instead of Project Config. Resolution is deliberately lenient — an unset or empty variable sends an empty credential rather than throwing, since a local environment legitimately leaves one blank. Third-party strategies register via AuthService::EVENT_REGISTER_AUTH_TYPES or ->auth->register() (see Registries).

A strategy implements AuthStrategyInterface: three static descriptors of the class — type() (the stored discriminator and registry key), label() (CP dropdown) and schema() (the form the CP renders for it, a SchemaBuilder) — plus an instance apply() returning the headers / query params for one request. Extending AbstractAuthStrategy makes it a Craft model, so per-type validation goes in defineRules(). Per request, the link's stored auth slice is handed to the constructor as its last argument.

Events

Hook into any stage:

  • LinksService::EVENT_BEFORE_SAVE_LINK / EVENT_AFTER_SAVE_LINK
  • LinksService::EVENT_BEFORE_DELETE_LINK / EVENT_AFTER_DELETE_LINK
  • SynchronizationService::EVENT_BEFORE_SYNC_LINK — once per run, and cancellable: $event->isValid = false cancels every site that run would cover
  • SynchronizationService::EVENT_AFTER_SYNC_LINK — once per site log, carrying that site's siteHandle and counters
  • SynchronizationService::EVENT_BEFORE_ITEM — set $event->skip = true or swap $event->element to redirect
  • SynchronizationService::EVENT_AFTER_ITEM_MAPPING — mappings have been applied but the element hasn't been saved
  • SynchronizationService::EVENT_AFTER_ITEM$event->action is created / updated / unchanged / error. Skipped items return before this fires, and the missing-elements sweep's outcomes (disabled, deleted, …) are log rows only, never event payloads
  • EndpointTokensService::EVENT_DEFINE_ENDPOINT_TOKENS — mutate $event->tokens to add / override / remove tokens substituted into the link's Resource Endpoint URL
  • EndpointTokensService::EVENT_DEFINE_ENDPOINT_TOKEN_SUGGESTIONS — append entries to $event->suggestions so plugin-contributed tokens show up in the edit-screen "Insert token" picker
  • TargetsService::EVENT_REGISTER_TARGETS — mutate $event->targets (see Registries)
  • FieldsService::EVENT_REGISTER_FIELDS — mutate $event->fields
  • AuthService::EVENT_REGISTER_AUTH_TYPES — mutate $event->authTypes to add auth strategies alongside the built-in Basic / Bearer / Custom Header / Query String
  • Date::EVENT_REGISTER_FORMAT_OPTIONS — append feed-specific date formats to (or replace) the presets offered in the mapping UI's format picker. Fired off the Date class (not a service) and memoized per request, so attach it from your plugin's init()

Integrations

Code that exists to play nice with other plugins lives under src/integrations/<vendor>/<plugin>/, mirroring the plugin's own Composer name — so the directory says who owns the thing being integrated with, and two vendors shipping a similarly-named plugin can't collide. An integration keeps everything it needs in that directory: a README.md for anything an operator has to know (a required feed shape, a setup step), a resources/ folder for a Vue control and its CSS if the builder needs one — reached from the CP bundle through the @integrations alias — and its specs beside the code. Detail belongs in those READMEs rather than here, so this file doesn't grow a section per third-party field.

  • integrations/craftcms/feedme — converts Feed Me feeds into Influx links (see Migrating from Feed Me).
  • integrations/verbb/tablemaker — a field strategy for Table Maker. Its columns are per-entry content, not field settings, so the row takes one source node holding the whole table and the feed supplies the columns with the values. That means the feed has to speak a fixed shape: the format is documented in the integration's own README.
  • integrations/jalendport/preparse — a field strategy declaring that a Preparse field can't be mapped: its value is a Twig template the plugin re-renders on every element save, so the template always wins — over a sync and over anything an editor types. The row keeps its label and says so, rather than offering a mapping that would be discarded.

Planned target adapters for Solspace Calendar and Craft Commerce elements (see the Roadmap) will register their targets when those plugins are installed, following the same optional-dependency rule.

Anything in there treats the other plugin as optional: integrations read its tables or registered services defensively and never make Influx depend on it being installed.

Design decisions

  • Project Config, not custom YAML. Earlier drafts wrote feed YAML to config/influx/. That worked but reinvented the wheel — Craft's Project Config already does YAML round-tripping, allowAdminChanges gating, change tracking, and deploy ergonomics. Influx uses it.
  • One link = one canonical element across all sites. Multi-site links share the same match value across per-site endpoints; per-site Craft rows on that element receive site-localized data.
  • Change detection is mapping-driven. Each mapping implements hasChanged() because a single == against the element value gives false-positives on relations, dates, and structured fields like Matrix.

Roadmap

Shipped since the alpha: queue-job-based runs (one job per site, one feed page per step, resumable), missing-element reconciliation (disable / disable-for-site / delete / delete-for-site, gated by endpoint shape), mapping strategies for every native Craft field type — relations, options, dates, assets, rich text, Matrix, Table, Content Block, Addresses, Link, and the single-value types (Time, Money, Colour, Country, Icon, JSON, Range) — and a target for every native Craft element type. Plain Text, Number and Email stay on the DefaultField fallback deliberately: a raw write is the right write for them.

Still open:

  • Targets for third-party element types. The native six ship, and so does Calendar's Event; the rest arrive when their plugin is installed, following the same optional-dependency rule as the other integrations — isAvailable() keeps each one inert until then:
  • id and uri as match attributes. Both are offered in the Match dropdown, but Link::validateMatch() needs the match attribute to have a mapping row with a source node — and neither has one, since neither is writable. The fix is a read-only mapping row: a source-node cell whose value the applier never writes, which is what GlobalSetTarget used to do for handle before its match key went away.
  • Require elementCriteria on a link that needs no match. With requiresMatch() false the criteria are the only thing identifying the element, so an unset one resolves nothing and every item reports "no element to write to". That's a clear run-time report, but a validation error on save would be earlier and cheaper.
  • Strategies for third-party field types. The same extension point, for the field types sites actually install alongside the natives. Super Table 4.x is entry-type based on Craft 5, so it's structurally what Matrix already does; Linkit's value object mirrors the native Link field. SEOmatic's SeoSettings and Freeform's form picker are container-shaped and still on the fallback. (CKEditor and Redactor already work — both extend craft\htmlfield\HtmlField, which RichText is keyed on.)
  • Match-by lookup for element-typed links. A Link mapping pointing at an entry, asset or category takes the element's ID; matching a title or slug the way the relational strategies do would need the match-by apparatus for a single lookup.
  • Per-address drill-down in the inspectors. Matrix and Table report what happened to each child; an Addresses row reports as one value.
  • Matrix per-block merge (today every sync fully replaces a Matrix field's blocks — as do Table and Addresses). Ordering is done: blocks come out in the feed's order.
  • Relative node suggestions for a Matrix's sub-fields. Node discovery only ever produced absolute paths, so a block's sub-field paths are typed rather than picked (the select does accept custom values).
  • Blocks from several unrelated lists. A Matrix reads one list, which is what makes its order the feed's; a field whose block types genuinely live in different parts of an item (a cast type under actors, a crew type under directors) can't be mapped in one row today.

Acknowledgements

Influx is heavily inspired by Feed Me (craftcms/feed-me). Its mapping model — per-field-type strategies, relation sub-fields, asset upload-on-import, and change detection before save — follows trails Feed Me blazed. Influx makes different trade-offs (JSON-only, Project Config-backed, hydrating existing element types rather than owning its own), but it stands on Feed Me's shoulders, and the integrations/craftcms/feedme converter exists so you can bring your existing feeds along.

License

MIT.