Lazaro – Publicador Social
Lazaro – WordPress plugin that auto-publishes posts to X, Facebook Pages and Instagram
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/crocdc/lazaro/archive/refs/heads/main.zipLazaro – Social Auto-Publisher
Standalone WordPress plugin that publishes posts to a site's social networks (X / Twitter, Facebook Page, Instagram). It auto-publishes when a post is published, re-posts when the title or featured image changes, and offers a manual per-post button to (re)publish to selected networks.
It is site-agnostic: every network is credential-driven from the admin screen, so the same plugin serves any number of sites.
The plugin UI is Spanish-only. Source strings are written in Spanish and
there is no translation catalogue: the sites it serves are Argentinian, and a
single language keeps the screens and the shipped guide in sync. The credential
field names (API Key, Access Token, Page ID, Page Access Token,
Instagram Business Account ID) stay untranslated on purpose — they are the
literal names X and Meta use in their consoles, which is what makes the guide's
field-by-field mapping work.
Content published per post: title + link + featured image.
- X / Facebook: text (title) + the note's link; the featured image shows up through the note's Open Graph card on the link preview.
- Instagram: image-first — it uploads the featured image with the title + link as the caption. A post without a featured image is skipped on Instagram.
Install
The repository root is the plugin, so it drops straight into a WordPress install:
git clone <repo> wp-content/plugins/lazaro-social # or web/app/plugins on Bedrock
wp plugin activate lazaro-social
On a Bedrock site managed by Composer, add it as a VCS repository instead
(composer.json here already declares type: wordpress-plugin, so
composer/installers puts it under web/app/plugins/):
{
"repositories": [
{ "type": "vcs", "url": "<repo url>" }
],
"require": {
"nexttech/lazaro-social": "^1.0"
}
}
Then configure the credentials at Settings → Lazaro Redes.
That screen also offers a downloadable PDF guide (in Spanish) walking through
how to obtain each network's credentials, screenshot by screenshot — including
the two traps that silently break publishing: an X token minted before write
permissions were set, and an Instagram account not linked to its Facebook Page.
Its source lives in docs/credentials-guide/.
Migrating from accesonorte-social
This plugin started life as accesonorte-social, embedded in the Acceso Norte
site repo. On a site that ran that version, deactivate the old plugin first,
then activate this one: on its first load it imports the legacy settings, queue
and per-post state (an_social_* → lazaro_social_*).
The import copies, never moves — the old rows stay untouched, so falling back to the old plugin loses nothing. Running both at once would double-post.
Delivery is asynchronous (bulletproof)
Publishing never blocks saving the post. Jobs go into a queue processed in the background by WP-Cron, with:
- Atomic locking (
add_optionUNIQUE-key mutex) + job leasing so concurrent cron runs / saves never double-post or corrupt the queue. - Retries with backoff (1m → 5m → 15m → 1h → 3h, up to 5 attempts) on transient errors only (HTTP 429 / 5xx / network timeouts). Permanent errors (bad credentials, missing permission, invalid content) are not retried.
- Cron kick: a non-blocking loopback to
wp-cron.phpso jobs run within seconds instead of waiting for the next visitor. - Idempotency: at most one pending job per (post, network); networks already up to date are skipped, so re-saving an unchanged post posts nothing.
The editor metabox polls a status endpoint, so outcomes (published / out of date / queued / failed) appear live without leaving the screen.
Updates: "re-post on change"
When a published post's title or featured image changes, the plugin makes each network reflect the new content. Platform capabilities differ:
| Network | On change |
|---|---|
| Deletes the previous Page post and publishes a fresh one. | |
| X | Deletes the previous tweet (DELETE /2/tweets/:id) and re-posts. |
| The API cannot edit or delete media, so it posts a new item (the old one stays — this is the only option Instagram offers). |
A changed post is detected via a content fingerprint (title + image + link)
stored per network. The metabox shows an "out of date" badge when a live post
no longer matches the current content.
Deletion: removing a note removes its social posts
When a note is trashed (what "delete" does in the editor) or permanently deleted, the items it published are removed from each network that can delete via API:
| Network | On delete |
|---|---|
Deletes the Page post (DELETE /{post-id}). |
|
| X | Deletes the tweet (DELETE /2/tweets/:id). |
| The API cannot delete media, so its post stays (delete it by hand). |
The deletion uses the same async queue as publishing: it is non-blocking, retries on transient errors, and snapshots the remote id into the job so it still runs after the post's meta is gone. Trashing also cancels any pending publish for that note, and clears its "published" pointer so restoring from the Trash re-publishes a fresh post (Instagram keeps its original post and pointer, since it could not be deleted).
Triggers
- Auto (
transition_post_statustopublish): on first publish and on every later save of a published post. Gated by the global Auto-publish setting and a per-post checkbox. The Publisher decides per network: publish / re-post / nothing. - Manual: the Redes Sociales metabox button enqueues the selected networks and forces a (re)post even if unchanged.
- Bulk / back-publish old notes: the Posts list has a "Publish to social networks" bulk action. Select any existing notes and it enqueues them — handy for notes that predate the plugin or were published while a network was off. It is not forced: each note is published only where it is not already live, so re-running never duplicates; it just fills the gaps.
Credentials
Everything is credential-driven. A network stays disabled until it is enabled and every credential is filled in at Settings → Lazaro Redes.
X (Twitter)
OAuth 1.0a user context, App with Read and Write: API Key, API Key Secret, Access Token, Access Token Secret.
A Page: Page ID + a long-lived Page Access Token with
pages_manage_posts (delete also needs the same token).
A Business/Creator account linked to the Page + a Meta App with
instagram_content_publish (Meta App Review for production): Instagram Business Account ID + Access Token.
Layout
lazaro-social.php plugin header, constants, autoloader, bootstrap
includes/
class-plugin.php container: network registry + hook wiring
class-settings.php settings store + Settings → Lazaro Redes screen
class-publisher.php decides publish / re-post / skip per network
class-queue.php async delivery queue (locking, leasing, backoff)
class-state.php per-post publishing state in post meta
class-post-meta.php editor metabox + its AJAX endpoints
class-deleter.php removes social posts when a note is trashed
class-bulk.php "Publish to social networks" bulk action
class-payload.php title + link + image payload, caption filter
class-migrator.php one-shot import of legacy accesonorte-social data
networks/ one class per network, behind a shared interface
assets/ admin CSS + JS (metabox and settings screen)
Extending
Add a network by implementing Lazaro_Social_Network (extend
Lazaro_Social_Network_Base) and registering it in
Lazaro_Social_Plugin::register_networks(); the settings screen renders its
fields from settings_fields().
Customise the caption sent to each network with the lazaro_social_caption
filter:
add_filter( 'lazaro_social_caption', function ( $caption, $network, $payload ) {
return 'x' === $network ? $caption . ' #Noticias' : $caption;
}, 10, 3 );
Notes / limitations
- X media is not uploaded directly; it relies on the link's Open Graph card.
- Instagram needs a publicly reachable featured-image URL (fetched by Meta's servers) — fine for a live site, not for localhost.
- Instagram cannot be edited/deleted via API, so re-posts there create a new item rather than replacing the old one, and trashing a note leaves its Instagram post in place (delete it by hand).
- Deletion fires on trash (and on permanent delete), not on merely unpublishing a post (publish → draft): an unpublished note keeps its social posts. Trash it to remove them.
- Only the
postpost type is handled.