Use this skill when working with social links, share buttons, customer reviews or the publication on the social networks in a Symfony application built on the c975L ecosystem with c975l/social-bundle. Covers the site-wide social links row, the share buttons band and its shapes and fills, the three block kinds, the network icons, the site-wide auto-display, the CSS tokens, the Google Business Profile review import with its pluggable sources, and the scheduled posts on Bluesky, Facebook, Instagram and LinkedIn, the publications calendar and the AI-written texts. Triggers on: social_links, social_links_display, share_buttons_display, share_buttons, share_buttons_default, share_buttons_edit_url, social_link_block, social_link_icon, social-enable-share-buttons, ui-enable-reviews, ReviewsSourceInterface, ReviewsReplySourceInterface, ReviewSynchronizer, ReviewReplyPublisher, c975l:social:reviews:sync, social-google-oauth-client-id, block-thumbs, BundleStylesheetManagementProviderInterface, ui.management_stylesheet, SocialBlockCacheTagProvider, BlockCacheTagProviderInterface, startStimulusApp, c975lStimulusApp, label.group_social, social config group, social links, share buttons, network icon, brand color, customer reviews, Google reviews, Google Business Profile, c975l:social:publish, SocialPublisher, SocialPost, SocialPostTarget, SocialPostCrudController, NetworkPublisherInterface, SocialContentSourceInterface, SocialContent, social-publish-template, social-meta-app-id, social-bluesky-oauth-session, SocialConnectionsController, Connexions, BlueskyOAuthClient, Es256Signer, DPoP, MetaGraphClient, SocialImageExporter, post on Bluesky, post on Facebook, post on Instagram, LinkedInClient, LinkedInPublisher, social-linkedin-client-id, post on LinkedIn, SocialPlanner, plannedAt, SocialCalendarController, SocialPostWriter, social-ai-guidelines, social publishing, SocialSeries, prolong a series, c975l:social:series:ending, changeContent, retryFailed, SocialCalendarFeed, iCal feed, SocialMedia, SocialMediaPicker, social_calendar_medias, sortMedias, hasGoneOut, approve-series, findComingBySeries, SocialPostApprover, social-meta-page-name, social-meta-instagram-username, calendar day view.
php artisan boost:add-skill c975l/social-bundle
Save this content to: .claude/skills/c975l-social/SKILL.md
# c975L SocialBundle
> The social side of a c975L site — one site-wide list of social links, and share buttons for 20 networks, both placed anywhere as blocks, the Google reviews feed, and the site's contents posted on its networks. Replaces the former ShareButtonsBundle.
**Package:** `c975l/social-bundle` · **Namespace:** `c975L\SocialBundle\` · **Twig namespace:** `@c975LSocial` · **Translation domain:** `social`
**Key source paths** (relative to the package root):
`src/Service/ShareButtonsService.php`, `src/Twig/`, `src/Form/Block/`, `src/Controller/`, `src/Controller/Management/`, `src/Management/`, `src/Contract/`, `src/Command/ReviewsSyncCommand.php`, `src/Command/PublishCommand.php`, `src/Service/SocialPublisher.php`, `src/Entity/`, `templates/blocks/`, `templates/components/SocialLinks.html.twig`, `templates/shareButtons/`, `config/configs.json`, `config/services.yaml`, `translations/config.en.xlf`, `public/icons/`, `sass/_social-brand-colors.scss`, `sass/block-thumbs.scss`, `scaffold/assets/styles/themes/social.css`
**Related documentation:** this package's `README.md` is the exhaustive reference. The block system, the icon service and the media library it builds on live in `c975l/core-bundle`.
## Quick start
```bash
composer require c975l/social-bundle
php bin/console c975l:config:load-all
php bin/console assets:install --symlink
php bin/console c975l:scaffold:install # copies assets/styles/themes/social.css into the app
```
Social links and share buttons need **no route, no entity and no migration**: both are stored as a
singleton UiBundle `Block`, edited from their own dashboard screens.
Customer reviews need the routes — see [Customer reviews](#customer-reviews). The publication needs
the routes and a migration too — see [Publishing on the networks](#publishing-on-the-networks).
## The two singleton features, and where their data lives
| Feature | Stored as | Edited in |
| --- | --- | --- |
| Social links | one `social_links` block | **Réseaux sociaux** (`SocialLinksCrudController`) |
| Share buttons settings | one `share_buttons_settings` block | **Boutons de partage** (`ShareButtonsSettingsCrudController`) |
A `Block` can normally only be created by attaching it to a page, so each screen is a small dashboard
CRUD scoped to its own kind. Both sit behind ConfigBundle's `site-role-editor` setting.
## Block kinds
| Kind | Pickable | What it does |
| --- | --- | --- |
| `social_links` | no | the singleton itself: an ordered list of `network` + `url`, an optional rich-text intro, the icon style and the label visibility |
| `social_links_display` | yes | a thin pointer dropping the same site-wide links into any page's block flow |
| `share_buttons_display` | yes | the same, for the dashboard-defined share buttons; its one field is an anchor |
The two `*_display` kinds **store no data of their own** — their form has no display field and their
template renders the singleton. That is the point: one list, edited once, shown wherever it is needed,
never re-entered per page.
## Rendering
```twig
{# the social links row, anywhere in a layout - footer, navbar, a page of your own #}
<twig:c975LSocial:SocialLinks/>
{# share buttons, always-manual entry point, independent of the site-wide setting #}
{{ share_buttons() }}
{{ share_buttons(['facebook', 'linkedin', 'email'], 'ellipse') }}
{{ share_buttons('main', 'circle', 'outline') }}
```
`share_buttons(networks, shape, fill, alignment, displayIcon, displayText, url, id, displayIntro)` —
`networks` defaults to `'main'` (facebook, bluesky, linkedin, pinterest, email), `shape` to `'wide'`,
`fill` to `'solid'`, `url` to the current page.
**Shape and fill are independent.** Shape is the box (`wide`, `ellipse`, `square`, `rounded`,
`circle`), fill is what paints it (`solid` brand color, `outline` ring, `minimal` icon alone,
`transparent` veil) — the 20 combinations are all reachable. They replaced a single `style` parameter
whose values are **gone, not mapped**: a call still passing one renders at the defaults.
Other Twig functions: `social_link_block()` (the singleton block), `social_link_icon()`,
`share_buttons_default(id)` (the dashboard-defined band), `share_buttons_edit_url()`.
## Site-wide auto-display
Two pieces: the **Boutons de partage** screen picks the networks, their order, the shape, the fill, the
invitation line and an optional anchor; the `social-enable-share-buttons` config key (bool, `false` by
default) turns the band on for every page. The reviews have the same kind of switch, `ui-enable-reviews`
(see [Customer reviews](#customer-reviews)).
Off, the settings screen stays in the sidebar and its index says the band is off, with a link to that
config's own edit form (UiBundle's `ConfigEditUrlResolver`) for `site-role-admin`, the bar
`ConfigCrudController` states. Never hide a screen: one governing nothing yet says so itself.
The band itself is this bundle's `templates/shareButtons/default.html.twig`, and a layout includes it:
```twig
{{ include('@c975LSocial/shareButtons/default.html.twig', ignore_missing: true) }}
```
**That path is a public contract** — renaming it is a BC break. The `include` is what keeps this bundle
optional: it resolves at runtime, where a direct `share_buttons_default()` call would fail at compile
time on a site not installing it. SiteBundle's layout already carries this line, outside `<main>`.
Hovering the band as an editor raises UiBundle's floating **Editer** button, pointing at the settings
screen.
## Icons
Networks are resolved by key through UiBundle's `IconServiceInterface`, which merges every bundle's
`icons/` directory by filename. This bundle ships 37 flat single-color SVG glyphs under `public/icons/`
(Font Awesome Free 6.5.1, CC BY 4.0 — keep the attribution if you redistribute them).
- **To override one**, drop your own `public/icons/{network}.svg` in the app: the app's directory is
read last and wins over every bundle's.
- Between bundles, the last `public/bundles/*/icons/` in alphabetical order wins, so a same-named file
in a package sorting after `c975lsocial` silently shadows the one shipped here.
- There is no pre-colored "official logo" asset: the colored badge is CSS, inverting the black glyph to
white over a brand-colored background. Every icon exists once.
## Styling
The links row carries `.social-links--minimal` / `--colored` / `--outline` / `--text` and each entry a
`.social-link--{network}` hook. Brand colors for both features come from
`sass/_social-brand-colors.scss`.
The share band is retuned through custom properties, each read with its shipped default as its own
fallback: `--page-share-margin-top`, `--social-share-display`, `--social-share-background`,
`--social-share-padding`, `--social-share-gap`, `--social-share-btn-width` / `-height` / `-radius` /
`-margin`, `--social-share-btn-background` / `-hover`, `--social-share-icon-filter`.
The four box tokens have a **per-variant default**, one value per shape or fill: declaring one in
`:root` collapses every variant into a single look and the dashboard's shape and fill then change
nothing visible. `scaffold/assets/styles/themes/social.css` is the catalogue of what is meant to be set
site-wide, every line shipped commented out at its default.
`sass/block-thumbs.scss` is a sheet apart, and no part of the site's look: it draws the two pickable
kinds at thumbnail size for the back-office's visual block picker, through EasyAdmin's own `--bs-*`
variables, so a site has nothing to retune there. `StylesheetProvider` serves it through
`getManagementStylesheets()` (`ui.management_stylesheet`), the management screens only — a site
wanting the same silhouettes on a public showcase page contributes the file from its own provider.
## Customer reviews
**The reviews themselves live in UiBundle** (`c975L\UiBundle\Entity\Review`, table `site_review`),
alongside what visitors write on the site: the two are the same thing seen from two sides, and only
`source` tells them apart. Moderation, display and the `ui-enable-reviews` switch are all UiBundle's —
see the `c975l-blocks` skill. This bundle owns **the feed alone**: connecting a platform, pulling its
reviews in, and pushing an answer back out.
| Piece | Class |
| --- | --- |
| Source contract | `Contract\ReviewsSourceInterface` (`getName()`, `isConfigured()`, `fetch()`) |
| Reply capability | `Contract\ReviewsReplySourceInterface` (adds `reply()`) |
| Normalized payload | `Model\ReviewData` |
| Google source | `Service\GoogleBusinessProfileSource` |
| OAuth | `Service\GoogleOAuthClient`, `Controller\GoogleOAuthController` |
| Import | `Service\ReviewSynchronizer`, `Command\ReviewsSyncCommand` |
| Reply out | `Service\ReviewReplyPublisher` |
Sources are auto-tagged by interface (`social.reviews_source`, see `c975LSocialBundle::build()`), so a
new platform is a class implementing `ReviewsSourceInterface` and nothing else.
A run upserts on `(source, external_id)`, marks every imported row `published` — the platform moderated
it already — and removes the reviews its source no longer returns (`ReviewRepository::findMissing()`),
so a review deleted on the platform goes here too. Unless the run brought nothing back at all: an empty
answer is what a revoked token or an exhausted quota looks like, and wiping the wall on one is worse
than showing it stale.
`ReviewReplyPublisher` implements **UiBundle's** `ReviewReplyPublisherInterface`, which is how the
moderation screen reaches it without this bundle owning the screen. Its `supports()` answers false for
a review written on the site and for a source no longer connected, which is what hides the reply field.
The `ui-enable-reviews` key only governs the public side; this bundle's two Google guided projects are always listed — connecting the listing being the agency's own job
(`ROLE_SUPER_ADMIN`, the OAuth keys being `restricted` configs, its `role` key also listing
`site-role-admin` and `site-role-editor`, the three screens that parcours walks demanding one each,
all required by `GuidedProjectBuilder`), reading and displaying the reviews the site's editor. The sync command is deliberately left running while it is off, so a reactivation
shows the reviews imported meanwhile.
**Setup in the consuming app**, on top of the usual `c975l:config:load-all`:
```yaml
# config/routes.yaml — this bundle's only routes
c975l_social:
resource: '@c975LSocialBundle/src/Controller/'
type: attribute
```
The `Review` table travels with UiBundle — this bundle's own entities are the publication's — and the sync needs no cron
entry of its own: `Scheduler\SocialMaintenanceTaskProvider` declares it nightly, so it is in the
scaffolded `MaintenanceSchedule` as soon as the bundle is installed. The five config slugs are
`social-google-oauth-client-id`, `social-google-oauth-client-secret`,
`social-google-oauth-refresh-token`, `social-google-business-account-id` and
`social-google-business-location-id` — only the first two are typed, `/social/google/connect` writing
the rest.
**All the settings of this bundle are filed under a `social` group of its own**, not under ConfigBundle's
shared `general`. A drawer named by a bundle is labelled by that bundle: `label.group_social` in the `config`
domain, shipped in `translations/config.{en,es,fr}.xlf`. Unlabelled, the "pick a group" screen shows the raw
key. `ConfigsJsonTest::testGroupsAreEitherSharedOrLabelledByThisBundle` locks that in. Needs
`c975l/core-bundle` `^1.23`, which no longer refuses a bundle-named group, and a
`c975l:config:load-all` for rows already in the database.
Google's reviews endpoints need the Cloud project to be **allowlisted** (7-10 business days) and the
OAuth app **published in production**, or its refresh tokens expire every seven days.
The connection is started from the Google tile of **"Connexions"** (`Controller\Management\SocialConnectionsController`,
an `#[AdminRoute]` screen linked from the sidebar's "Social" submenu), which draws one tile per network, with the accounts it reaches linked to their network.
`Service\SameAsProvider` implements UiBundle's `SameAsProviderInterface`, so a `contact_details` block
publishes the listing (`social-google-listing-url`, a `maps?cid=…` address) and the social links in its
`sameAs` — the property stating the site and those profiles are one business, where the block's own
`mapUrl` publishes `hasMap` and only says a map exists. Nothing is retyped into the contact form.
## Publishing on the networks
The site's contents posted on **Bluesky**, its **Facebook** Page, the **Instagram** professional
account linked to that Page and a member's **LinkedIn** profile (`LinkedInClient`, `LinkedInPublisher`: a
self-serve app, so no Company Page and a token to renew every 60 days, watched by `LinkedInHealthCheckProvider`) — on as soon as one of them is connected (`SocialPublisher::hasConnectedNetwork()`), no switch besides.
| Piece | Class |
| --- | --- |
| Content contract | `c975L\UiBundle\Contract\SocialContentSourceInterface`, handing out `c975L\UiBundle\Model\SocialContent` |
| Network contract | `Contract\NetworkPublisherInterface` (`getName()`, `isConfigured()`, `getMaxLength()`, `getMediaRules()`, `publish()`, `preview()`) |
| Networks | `Service\BlueskyPublisher`, `Service\FacebookPublisher`, `Service\InstagramPublisher`, `Service\LinkedInPublisher` |
| Meta connection | `Service\MetaGraphClient`, `Controller\MetaOAuthController` (`/social/meta/connect`, `/social/meta/callback`), `Controller\Management\SocialMetaPageController` (the Page picked, held 10 minutes in the session) |
| Bluesky connection | `Service\BlueskyOAuthClient`, `Service\Es256Signer`, `Controller\BlueskyOAuthController` (`/social/bluesky/connect`, `/social/bluesky/callback`) |
| Orchestration | `Service\SocialPublisher`, `Command\PublishCommand` (`c975l:social:publish`) |
| Stored posts | `Entity\SocialPost`, one `Entity\SocialPostTarget` per network, `Enum\SocialPostStatus` |
| Review screen | `Controller\Management\SocialPostCrudController` ("Publications", `site-role-editor`) |
| AI texts | `Service\SocialPostWriter` (Donovan's writer key, `social-ai-guidelines`), the template as fallback |
| Calendar | `Controller\Management\SocialCalendarController` ("Calendrier", `site-role-editor`), `Service\SocialPlanner`, `assets/js/social-calendar.js` |
| Calendar feed | `Service\SocialCalendarFeed`, `Controller\SocialCalendarFeedController` (`/social/calendar/{token}.ics`, secret `social-calendar-ics-token`) |
| Series | `Entity\SocialSeries`, `Service\SocialSeriesGenerator`, `Command\SeriesEndingCommand` (`c975l:social:series:ending`, `social-series-ending-days`) |
| Refusals | `Service\SocialAdminMailer` (emails `email-to`), `SocialPostCrudController::retryPost()` → `SocialPublisher::retryFailed()` |
A bundle owning contents implements `SocialContentSourceInterface`, declared in UiBundle so it needs no
dependency on this one; both contracts are auto-tagged by interface (`social.content_source`,
`social.network_publisher`). Every `SocialPost` holds a mandatory `plannedAt` from its construction, a
draft included: there is no queue and no post without a moment. A post is prepared as a draft, one text
per network written from `social-publish-template` (`{name}` placeholders) and cut to `getMaxLength()`,
never sent on preparation. "Valider" turns its targets `approved`; `c975l:social:publish`, every 15 minutes, sends the approved
targets whose `plannedAt` has come. "Publier" sends every target not out yet at once, under a per-post lock,
and is offered on the list only. `--url` prepares a draft of a page at `--at` (the next quarter of an hour
by default), `--dry-run` never calls the AI. `SocialPublisher::prepareDrafts()` takes one moment per draft
and source keys from `getSourceChoices()` (`gallery_media` or a group `gallery_media:3`, from
`ScopedSocialContentSourceInterface`). A post written on its screen (`SocialPost::isManual()`, `SocialPublisher::createManual()`, the CRUD's
"new" with `?at=`, which the calendar's double click opens) holds a `text`, Donovan on it; each network's
text is cut from it (`SocialPostTextBuilder::cut()`), and written again by `rewriteTargets()` when the text
changes. Its `url` may be empty. A post's own pictures and videos are `Entity\SocialMedia` (Vich mapping
`social_media`, `Namer\SocialMediaNamer`, a picture rewritten as one JPEG by `Service\SocialMediaFile` from
`Listener\SocialMediaUploadListener`, which also refuses to delete a file only referenced, an upload over a
reference included, `reference` cleared on `POST_UPLOAD` only); they reach the
networks as `Model\PostMedia`, checked against `Model\MediaRules` by `Service\SocialMediaChecker` before
approval. `Service\SocialMediaPicker` adds the medias of UiBundle's `PickableMediaProviderInterface` libraries
(tag `social.pickable_media`, action `pickMedia`): a picture copied as the post's JPEG, a video referenced,
each keeping the content it was picked from (`SocialMedia::setSource()`), which the post holds like its own.
`Service\SocialSeriesGenerator` makes a series of drafts (one text, `SocialPostWriter::variants()`, or
the sources), every N days, some weekdays or every month (counted from the first moment, the last day of a
shorter month), keeping nothing when no draft is made; `prolong()` makes as many again after its last post,
and `c975l:social:series:ending` emails the series ending soon, once per end. On a draft from a
`BrowsableSocialContentSourceInterface` source, `SocialPublisher::redraw()`/`changeContent()` move it to
another free content and write its texts again (a post with its own text keeps it);
`Service\SocialContentStatusProvider` tells the owning bundle which contents are reserved or published,
a post's own and its medias' alike, a published one never read as reserved again.
A refused target is emailed with a retry address, which sends the failed targets only - a draft stays a
draft. `c975l:social:media:purge` frees the uploads past `social-media-retention-days`. A post with no target is never published (`SocialPost::isPublished()`), and the CRUD requires one network.
The calendar lays every post not published yet at its
`plannedAt` (`SocialPostRepository::findPlannedBetween()`); a drag changes the moment only, never the
approval. Three views, `view=week` (default), `day` and `month`, a day's date opening its day. A post gone out shows one card per run in the week - its networks sent within a quarter of an
hour of the first - and one per day in the month (`SocialCalendarController::publishedItems()`). A card's panel also orders and removes the post's medias (`SocialCalendarController::medias()`,
route `social_calendar_medias`, `SocialPost::sortMedias()`), until the post has gone out on a network
(`SocialPost::hasGoneOut()`). It also approves the series' coming drafts at once (`to=approve-series`,
`SocialPostRepository::findComingBySeries()`, counted by `countComingDraftsBySeries()`), a moment gone or a post refused by a
network left alone, every draft checked by `SocialMediaChecker` through `SocialPostApprover::approveAll()` - the list's
"Valider la sélection" going through it too.
Bluesky's tile is the AT Protocol's OAuth with the site as its own confidential client: no app, no
password. The session sits in `social-bluesky-oauth-session`, the client key in `social-bluesky-oauth-key`;
the single-use refresh token is renewed under a lock, from a fresh read of the session. The key id is the
key's RFC 7638 thumbprint, and the connection only starts from the `site-url` host (Bluesky reads the
client there, so another host's key would be refused). There is no app-password fallback.
The Meta keys `social-meta-app-id` and `social-meta-app-secret` are `restricted`; the Meta tile fills
the Page, its token, the Instagram id and their names (`social-meta-page-name`, `social-meta-instagram-username`, shown and linked on the tile), an account managing several Pages picking the site's one on
"Choisir la Page" (`SocialMetaPageController::index()`). Meta gets a JPEG copy written by
`SocialImageExporter` under `public/medias/social/`, never the site's WebP.
## What the bundle already contributes
Nothing below is declared in the app: `MenuProvider` (the dashboard entries, each declaring
`site-role-editor` as the bar its own screen states, plus the "Connexions" link drawn under "Social", every entry listed whatever the site uses), `ProcedureProvider`
(the admin help procedures), `SocialGuidedProjectProvider` (the guided walk-through of each screen,
offered only to who can open it), `WhatsNewProvider`, `ImportmapProvider`, `Service\ScriptProvider`,
`Service\StylesheetProvider` (the public sheet and the back-office silhouettes both),
`Service\BlockFixtureProvider`, `Service\SocialDemoFixtureProvider` (the two Google reviews a demo
site shows, UiBundle's entity carrying this bundle's rows), `Service\SocialBlockCacheTagProvider` (the
`singleton_block_social_links` tag the `social_links_display` entry carries on top of its own), an
export/import provider per
singleton (`SocialLinksExportProvider`, `ShareButtonsSettingsExportProvider` and their import twins),
`Management\GoogleReviewsHealthCheckProvider` (one row on the health check page, saying whether the
Google connection still answers - the import being the one thing here that stops silently) and
`Scheduler\SocialMaintenanceTaskProvider` (the nightly review sync and the publication run every 15 minutes).
## Do not
- **Do not create an entity or a table for social links or share settings.** Both are singleton blocks;
adding a table duplicates data the dashboard already owns.
- **Do not call `share_buttons_default()` from a layout.** Use the documented `include` with
`ignore_missing`, or the site breaks for anyone not installing this bundle.
- **Do not rename `templates/shareButtons/default.html.twig`** — a layout elsewhere includes it by path.
- **Do not pass the old `style` argument** to `share_buttons()`. Pass `shape` and `fill`.
- **Do not re-ship a `{network}.svg` another bundle already carries** — one of the two is shadowed,
silently, on nothing but the directory order.
- **Do not set `--network-color` in `:root`.** It is declared per network; one value paints every
button alike.
- **Do not give the `*_display` blocks fields of their own.** They are pointers on purpose; storing a
copy is what makes a page's links drift from the site's.
- **Do not send failed targets with `SocialPublisher::publish()`.** It sends drafts too; a retry goes
through `retryFailed()`.
- **Do not file a setting under a drawer without labelling it.** A `group` outside `Config::GROUPS` needs its
`label.group_<slug>` in `translations/config.*.xlf`, in each of the three locales, or the back office shows
the key.
- **Do not add a page layout to this bundle.** A satellite never ships one.
- **Do not clear `SocialMedia::$reference` before Vich has handled the old file.** `PRE_REMOVE` reads it
to spare a gallery's file; cleared in `setFile()`, an upload deletes it.
- **Do not call `startStimulusApp()` bare in a barrel.** Each call also registers whatever the app's
`controllers.json` enables, so a second application builds the `live` controller a second time and a
Live Component answers twice. Join the page's one application with
`globalThis.c975lStimulusApp ??= startStimulusApp()`.
- **Do not map a review entity of your own here.** The rows belong to UiBundle, which holds what
visitors write on the site in the same table; a second one would split the wall in two.
- **Do not edit, delete or hide an imported review anywhere.** A review is its author's statement:
rewriting it falsifies it, and dropping the ones that displease is what art. L111-7-2 of the French
consumer code forbids — while the review stays published on the platform anyway. Only the public
reply is writable, and it goes to the platform before it is stored.
- **Do not call a review platform from a page render.** Quotas are counted per call and the site must
keep serving its reviews while the platform is down — `c975l:social:reviews:sync` runs on cron.
- **Do not add an `extra` section to the status report.** This bundle holds two back-office
singletons and no queue of its own, so it has no figure a maintainer would act on that morning.
- **Do not raise a back-office alert on the Google connection.** `GoogleReviewsHealthCheckProvider`
already states both failing cases, and an alert cannot call Google from a page render.
- **Do not register a `FormThemeProviderInterface` or a `BlockEditUrlProviderInterface` here.** The two
CRUD form themes are local, passed through `Crud::addFormTheme()`; and the share band is no `Block`
on a page, its edit url already served by `share_buttons_edit_url()`. Nothing is offered to the
sitemap or the linkable routes either: every route is an editor-only redirection.
- **Do not make a content bundle require this one to be posted.** Implement UiBundle's
`SocialContentSourceInterface`; the ids already posted are this bundle's to track, not the source's.
- **Do not call a network from a page render or a listener.** Posts go out from `c975l:social:publish`
or the screen's "Publier", which keep the network's refusal on the target.
- **Do not offer "Publier" on the post's edit page.** A link does not submit the form: the text sent
would be the saved one, not the one just corrected.
- **Do not hand a network the picture's bytes without `SocialImageExporter::bytes()`.** With
`social-image-format` set to `square`, every network has to show the same 1080 x 1080 visual.
- **Do not change the medias of a post gone out on a network.** Removing one deletes its file and frees
its content while a network already shows it; guard with `SocialPost::hasGoneOut()`, not the
`published` state.
- **Do not create a `SocialPost` without a moment, nor approve on a drag.** The calendar shows every post
at its `plannedAt`, and the planned run is the only clock; a drag moves, "Valider" approves.
- **Do not hand Meta the site's WebP url.** Go through `SocialImageExporter::jpegUrl()`.
- **Do not make `share_buttons_display` cacheable.** Its render carries the current page's url, so the
first page's share links would be served on every other page holding that block.
`social_links_display` is the opposite case and is cached, the singleton's own tag added on top of
its own - see `Service\SocialBlockCacheTagProvider`.
The social side of a c975L site — one site-wide list of social links, and share buttons for 20 networks, both placed anywhere as blocks, the Google reviews feed, and the site's contents posted on its networks. Replaces the former ShareButtonsBundle.
Package: c975l/social-bundle · Namespace: c975L\SocialBundle\ · Twig namespace: @c975LSocial · Translation domain: social
Key source paths (relative to the package root):
src/Service/ShareButtonsService.php, src/Twig/, src/Form/Block/, src/Controller/, src/Controller/Management/, src/Management/, src/Contract/, src/Command/ReviewsSyncCommand.php, src/Command/PublishCommand.php, src/Service/SocialPublisher.php, src/Entity/, templates/blocks/, templates/components/SocialLinks.html.twig, templates/shareButtons/, config/configs.json, config/services.yaml, translations/config.en.xlf, public/icons/, sass/_social-brand-colors.scss, sass/block-thumbs.scss, scaffold/assets/styles/themes/social.css
Related documentation: this package's README.md is the exhaustive reference. The block system, the icon service and the media library it builds on live in c975l/core-bundle.
composer require c975l/social-bundle
php bin/console c975l:config:load-all
php bin/console assets:install --symlink
php bin/console c975l:scaffold:install # copies assets/styles/themes/social.css into the app
Social links and share buttons need no route, no entity and no migration: both are stored as a
singleton UiBundle Block, edited from their own dashboard screens.
Customer reviews need the routes — see Customer reviews. The publication needs the routes and a migration too — see Publishing on the networks.
| Feature | Stored as | Edited in |
|---|---|---|
| Social links | one social_links block |
Réseaux sociaux (SocialLinksCrudController) |
| Share buttons settings | one share_buttons_settings block |
Boutons de partage (ShareButtonsSettingsCrudController) |
A Block can normally only be created by attaching it to a page, so each screen is a small dashboard
CRUD scoped to its own kind. Both sit behind ConfigBundle's site-role-editor setting.
| Kind | Pickable | What it does |
|---|---|---|
social_links |
no | the singleton itself: an ordered list of network + url, an optional rich-text intro, the icon style and the label visibility |
social_links_display |
yes | a thin pointer dropping the same site-wide links into any page's block flow |
share_buttons_display |
yes | the same, for the dashboard-defined share buttons; its one field is an anchor |
The two *_display kinds store no data of their own — their form has no display field and their
template renders the singleton. That is the point: one list, edited once, shown wherever it is needed,
never re-entered per page.
{# the social links row, anywhere in a layout - footer, navbar, a page of your own #}
<twig:c975LSocial:SocialLinks/>
{# share buttons, always-manual entry point, independent of the site-wide setting #}
{{ share_buttons() }}
{{ share_buttons(['facebook', 'linkedin', 'email'], 'ellipse') }}
{{ share_buttons('main', 'circle', 'outline') }}
share_buttons(networks, shape, fill, alignment, displayIcon, displayText, url, id, displayIntro) —
networks defaults to 'main' (facebook, bluesky, linkedin, pinterest, email), shape to 'wide',
fill to 'solid', url to the current page.
Shape and fill are independent. Shape is the box (wide, ellipse, square, rounded,
circle), fill is what paints it (solid brand color, outline ring, minimal icon alone,
transparent veil) — the 20 combinations are all reachable. They replaced a single style parameter
whose values are gone, not mapped: a call still passing one renders at the defaults.
Other Twig functions: social_link_block() (the singleton block), social_link_icon(),
share_buttons_default(id) (the dashboard-defined band), share_buttons_edit_url().
Two pieces: the Boutons de partage screen picks the networks, their order, the shape, the fill, the
invitation line and an optional anchor; the social-enable-share-buttons config key (bool, false by
default) turns the band on for every page. The reviews have the same kind of switch, ui-enable-reviews
(see Customer reviews).
Off, the settings screen stays in the sidebar and its index says the band is off, with a link to that
config's own edit form (UiBundle's ConfigEditUrlResolver) for site-role-admin, the bar
ConfigCrudController states. Never hide a screen: one governing nothing yet says so itself.
The band itself is this bundle's templates/shareButtons/default.html.twig, and a layout includes it:
{{ include('@c975LSocial/shareButtons/default.html.twig', ignore_missing: true) }}
That path is a public contract — renaming it is a BC break. The include is what keeps this bundle
optional: it resolves at runtime, where a direct share_buttons_default() call would fail at compile
time on a site not installing it. SiteBundle's layout already carries this line, outside <main>.
Hovering the band as an editor raises UiBundle's floating Editer button, pointing at the settings screen.
Networks are resolved by key through UiBundle's IconServiceInterface, which merges every bundle's
icons/ directory by filename. This bundle ships 37 flat single-color SVG glyphs under public/icons/
(Font Awesome Free 6.5.1, CC BY 4.0 — keep the attribution if you redistribute them).
public/icons/{network}.svg in the app: the app's directory is
read last and wins over every bundle's.public/bundles/*/icons/ in alphabetical order wins, so a same-named file
in a package sorting after c975lsocial silently shadows the one shipped here.The links row carries .social-links--minimal / --colored / --outline / --text and each entry a
.social-link--{network} hook. Brand colors for both features come from
sass/_social-brand-colors.scss.
The share band is retuned through custom properties, each read with its shipped default as its own
fallback: --page-share-margin-top, --social-share-display, --social-share-background,
--social-share-padding, --social-share-gap, --social-share-btn-width / -height / -radius /
-margin, --social-share-btn-background / -hover, --social-share-icon-filter.
The four box tokens have a per-variant default, one value per shape or fill: declaring one in
:root collapses every variant into a single look and the dashboard's shape and fill then change
nothing visible. scaffold/assets/styles/themes/social.css is the catalogue of what is meant to be set
site-wide, every line shipped commented out at its default.
sass/block-thumbs.scss is a sheet apart, and no part of the site's look: it draws the two pickable
kinds at thumbnail size for the back-office's visual block picker, through EasyAdmin's own --bs-*
variables, so a site has nothing to retune there. StylesheetProvider serves it through
getManagementStylesheets() (ui.management_stylesheet), the management screens only — a site
wanting the same silhouettes on a public showcase page contributes the file from its own provider.
The reviews themselves live in UiBundle (c975L\UiBundle\Entity\Review, table site_review),
alongside what visitors write on the site: the two are the same thing seen from two sides, and only
source tells them apart. Moderation, display and the ui-enable-reviews switch are all UiBundle's —
see the c975l-blocks skill. This bundle owns the feed alone: connecting a platform, pulling its
reviews in, and pushing an answer back out.
| Piece | Class |
|---|---|
| Source contract | Contract\ReviewsSourceInterface (getName(), isConfigured(), fetch()) |
| Reply capability | Contract\ReviewsReplySourceInterface (adds reply()) |
| Normalized payload | Model\ReviewData |
| Google source | Service\GoogleBusinessProfileSource |
| OAuth | Service\GoogleOAuthClient, Controller\GoogleOAuthController |
| Import | Service\ReviewSynchronizer, Command\ReviewsSyncCommand |
| Reply out | Service\ReviewReplyPublisher |
Sources are auto-tagged by interface (social.reviews_source, see c975LSocialBundle::build()), so a
new platform is a class implementing ReviewsSourceInterface and nothing else.
A run upserts on (source, external_id), marks every imported row published — the platform moderated
it already — and removes the reviews its source no longer returns (ReviewRepository::findMissing()),
so a review deleted on the platform goes here too. Unless the run brought nothing back at all: an empty
answer is what a revoked token or an exhausted quota looks like, and wiping the wall on one is worse
than showing it stale.
ReviewReplyPublisher implements UiBundle's ReviewReplyPublisherInterface, which is how the
moderation screen reaches it without this bundle owning the screen. Its supports() answers false for
a review written on the site and for a source no longer connected, which is what hides the reply field.
The ui-enable-reviews key only governs the public side; this bundle's two Google guided projects are always listed — connecting the listing being the agency's own job
(ROLE_SUPER_ADMIN, the OAuth keys being restricted configs, its role key also listing
site-role-admin and site-role-editor, the three screens that parcours walks demanding one each,
all required by GuidedProjectBuilder), reading and displaying the reviews the site's editor. The sync command is deliberately left running while it is off, so a reactivation
shows the reviews imported meanwhile.
Setup in the consuming app, on top of the usual c975l:config:load-all:
# config/routes.yaml — this bundle's only routes
c975l_social:
resource: '@c975LSocialBundle/src/Controller/'
type: attribute
The Review table travels with UiBundle — this bundle's own entities are the publication's — and the sync needs no cron
entry of its own: Scheduler\SocialMaintenanceTaskProvider declares it nightly, so it is in the
scaffolded MaintenanceSchedule as soon as the bundle is installed. The five config slugs are
social-google-oauth-client-id, social-google-oauth-client-secret,
social-google-oauth-refresh-token, social-google-business-account-id and
social-google-business-location-id — only the first two are typed, /social/google/connect writing
the rest.
All the settings of this bundle are filed under a social group of its own, not under ConfigBundle's
shared general. A drawer named by a bundle is labelled by that bundle: label.group_social in the config
domain, shipped in translations/config.{en,es,fr}.xlf. Unlabelled, the "pick a group" screen shows the raw
key. ConfigsJsonTest::testGroupsAreEitherSharedOrLabelledByThisBundle locks that in. Needs
c975l/core-bundle ^1.23, which no longer refuses a bundle-named group, and a
c975l:config:load-all for rows already in the database.
Google's reviews endpoints need the Cloud project to be allowlisted (7-10 business days) and the OAuth app published in production, or its refresh tokens expire every seven days.
The connection is started from the Google tile of "Connexions" (Controller\Management\SocialConnectionsController,
an #[AdminRoute] screen linked from the sidebar's "Social" submenu), which draws one tile per network, with the accounts it reaches linked to their network.
Service\SameAsProvider implements UiBundle's SameAsProviderInterface, so a contact_details block
publishes the listing (social-google-listing-url, a maps?cid=… address) and the social links in its
sameAs — the property stating the site and those profiles are one business, where the block's own
mapUrl publishes hasMap and only says a map exists. Nothing is retyped into the contact form.
The site's contents posted on Bluesky, its Facebook Page, the Instagram professional
account linked to that Page and a member's LinkedIn profile (LinkedInClient, LinkedInPublisher: a
self-serve app, so no Company Page and a token to renew every 60 days, watched by LinkedInHealthCheckProvider) — on as soon as one of them is connected (SocialPublisher::hasConnectedNetwork()), no switch besides.
| Piece | Class |
|---|---|
| Content contract | c975L\UiBundle\Contract\SocialContentSourceInterface, handing out c975L\UiBundle\Model\SocialContent |
| Network contract | Contract\NetworkPublisherInterface (getName(), isConfigured(), getMaxLength(), getMediaRules(), publish(), preview()) |
| Networks | Service\BlueskyPublisher, Service\FacebookPublisher, Service\InstagramPublisher, Service\LinkedInPublisher |
| Meta connection | Service\MetaGraphClient, Controller\MetaOAuthController (/social/meta/connect, /social/meta/callback), Controller\Management\SocialMetaPageController (the Page picked, held 10 minutes in the session) |
| Bluesky connection | Service\BlueskyOAuthClient, Service\Es256Signer, Controller\BlueskyOAuthController (/social/bluesky/connect, /social/bluesky/callback) |
| Orchestration | Service\SocialPublisher, Command\PublishCommand (c975l:social:publish) |
| Stored posts | Entity\SocialPost, one Entity\SocialPostTarget per network, Enum\SocialPostStatus |
| Review screen | Controller\Management\SocialPostCrudController ("Publications", site-role-editor) |
| AI texts | Service\SocialPostWriter (Donovan's writer key, social-ai-guidelines), the template as fallback |
| Calendar | Controller\Management\SocialCalendarController ("Calendrier", site-role-editor), Service\SocialPlanner, assets/js/social-calendar.js |
| Calendar feed | Service\SocialCalendarFeed, Controller\SocialCalendarFeedController (/social/calendar/{token}.ics, secret social-calendar-ics-token) |
| Series | Entity\SocialSeries, Service\SocialSeriesGenerator, Command\SeriesEndingCommand (c975l:social:series:ending, social-series-ending-days) |
| Refusals | Service\SocialAdminMailer (emails email-to), SocialPostCrudController::retryPost() → SocialPublisher::retryFailed() |
A bundle owning contents implements SocialContentSourceInterface, declared in UiBundle so it needs no
dependency on this one; both contracts are auto-tagged by interface (social.content_source,
social.network_publisher). Every SocialPost holds a mandatory plannedAt from its construction, a
draft included: there is no queue and no post without a moment. A post is prepared as a draft, one text
per network written from social-publish-template ({name} placeholders) and cut to getMaxLength(),
never sent on preparation. "Valider" turns its targets approved; c975l:social:publish, every 15 minutes, sends the approved
targets whose plannedAt has come. "Publier" sends every target not out yet at once, under a per-post lock,
and is offered on the list only. --url prepares a draft of a page at --at (the next quarter of an hour
by default), --dry-run never calls the AI. SocialPublisher::prepareDrafts() takes one moment per draft
and source keys from getSourceChoices() (gallery_media or a group gallery_media:3, from
ScopedSocialContentSourceInterface). A post written on its screen (SocialPost::isManual(), SocialPublisher::createManual(), the CRUD's
"new" with ?at=, which the calendar's double click opens) holds a text, Donovan on it; each network's
text is cut from it (SocialPostTextBuilder::cut()), and written again by rewriteTargets() when the text
changes. Its url may be empty. A post's own pictures and videos are Entity\SocialMedia (Vich mapping
social_media, Namer\SocialMediaNamer, a picture rewritten as one JPEG by Service\SocialMediaFile from
Listener\SocialMediaUploadListener, which also refuses to delete a file only referenced, an upload over a
reference included, reference cleared on POST_UPLOAD only); they reach the
networks as Model\PostMedia, checked against Model\MediaRules by Service\SocialMediaChecker before
approval. Service\SocialMediaPicker adds the medias of UiBundle's PickableMediaProviderInterface libraries
(tag social.pickable_media, action pickMedia): a picture copied as the post's JPEG, a video referenced,
each keeping the content it was picked from (SocialMedia::setSource()), which the post holds like its own.
Service\SocialSeriesGenerator makes a series of drafts (one text, SocialPostWriter::variants(), or
the sources), every N days, some weekdays or every month (counted from the first moment, the last day of a
shorter month), keeping nothing when no draft is made; prolong() makes as many again after its last post,
and c975l:social:series:ending emails the series ending soon, once per end. On a draft from a
BrowsableSocialContentSourceInterface source, SocialPublisher::redraw()/changeContent() move it to
another free content and write its texts again (a post with its own text keeps it);
Service\SocialContentStatusProvider tells the owning bundle which contents are reserved or published,
a post's own and its medias' alike, a published one never read as reserved again.
A refused target is emailed with a retry address, which sends the failed targets only - a draft stays a
draft. c975l:social:media:purge frees the uploads past social-media-retention-days. A post with no target is never published (SocialPost::isPublished()), and the CRUD requires one network.
The calendar lays every post not published yet at its
plannedAt (SocialPostRepository::findPlannedBetween()); a drag changes the moment only, never the
approval. Three views, view=week (default), day and month, a day's date opening its day. A post gone out shows one card per run in the week - its networks sent within a quarter of an
hour of the first - and one per day in the month (SocialCalendarController::publishedItems()). A card's panel also orders and removes the post's medias (SocialCalendarController::medias(),
route social_calendar_medias, SocialPost::sortMedias()), until the post has gone out on a network
(SocialPost::hasGoneOut()). It also approves the series' coming drafts at once (to=approve-series,
SocialPostRepository::findComingBySeries(), counted by countComingDraftsBySeries()), a moment gone or a post refused by a
network left alone, every draft checked by SocialMediaChecker through SocialPostApprover::approveAll() - the list's
"Valider la sélection" going through it too.
Bluesky's tile is the AT Protocol's OAuth with the site as its own confidential client: no app, no
password. The session sits in social-bluesky-oauth-session, the client key in social-bluesky-oauth-key;
the single-use refresh token is renewed under a lock, from a fresh read of the session. The key id is the
key's RFC 7638 thumbprint, and the connection only starts from the site-url host (Bluesky reads the
client there, so another host's key would be refused). There is no app-password fallback.
The Meta keys social-meta-app-id and social-meta-app-secret are restricted; the Meta tile fills
the Page, its token, the Instagram id and their names (social-meta-page-name, social-meta-instagram-username, shown and linked on the tile), an account managing several Pages picking the site's one on
"Choisir la Page" (SocialMetaPageController::index()). Meta gets a JPEG copy written by
SocialImageExporter under public/medias/social/, never the site's WebP.
Nothing below is declared in the app: MenuProvider (the dashboard entries, each declaring
site-role-editor as the bar its own screen states, plus the "Connexions" link drawn under "Social", every entry listed whatever the site uses), ProcedureProvider
(the admin help procedures), SocialGuidedProjectProvider (the guided walk-through of each screen,
offered only to who can open it), WhatsNewProvider, ImportmapProvider, Service\ScriptProvider,
Service\StylesheetProvider (the public sheet and the back-office silhouettes both),
Service\BlockFixtureProvider, Service\SocialDemoFixtureProvider (the two Google reviews a demo
site shows, UiBundle's entity carrying this bundle's rows), Service\SocialBlockCacheTagProvider (the
singleton_block_social_links tag the social_links_display entry carries on top of its own), an
export/import provider per
singleton (SocialLinksExportProvider, ShareButtonsSettingsExportProvider and their import twins),
Management\GoogleReviewsHealthCheckProvider (one row on the health check page, saying whether the
Google connection still answers - the import being the one thing here that stops silently) and
Scheduler\SocialMaintenanceTaskProvider (the nightly review sync and the publication run every 15 minutes).
share_buttons_default() from a layout. Use the documented include with
ignore_missing, or the site breaks for anyone not installing this bundle.templates/shareButtons/default.html.twig — a layout elsewhere includes it by path.style argument to share_buttons(). Pass shape and fill.{network}.svg another bundle already carries — one of the two is shadowed,
silently, on nothing but the directory order.--network-color in :root. It is declared per network; one value paints every
button alike.*_display blocks fields of their own. They are pointers on purpose; storing a
copy is what makes a page's links drift from the site's.SocialPublisher::publish(). It sends drafts too; a retry goes
through retryFailed().group outside Config::GROUPS needs its
label.group_<slug> in translations/config.*.xlf, in each of the three locales, or the back office shows
the key.SocialMedia::$reference before Vich has handled the old file. PRE_REMOVE reads it
to spare a gallery's file; cleared in setFile(), an upload deletes it.startStimulusApp() bare in a barrel. Each call also registers whatever the app's
controllers.json enables, so a second application builds the live controller a second time and a
Live Component answers twice. Join the page's one application with
globalThis.c975lStimulusApp ??= startStimulusApp().c975l:social:reviews:sync runs on cron.extra section to the status report. This bundle holds two back-office
singletons and no queue of its own, so it has no figure a maintainer would act on that morning.GoogleReviewsHealthCheckProvider
already states both failing cases, and an alert cannot call Google from a page render.FormThemeProviderInterface or a BlockEditUrlProviderInterface here. The two
CRUD form themes are local, passed through Crud::addFormTheme(); and the share band is no Block
on a page, its edit url already served by share_buttons_edit_url(). Nothing is offered to the
sitemap or the linkable routes either: every route is an editor-only redirection.SocialContentSourceInterface; the ids already posted are this bundle's to track, not the source's.c975l:social:publish
or the screen's "Publier", which keep the network's refusal on the target.SocialImageExporter::bytes(). With
social-image-format set to square, every network has to show the same 1080 x 1080 visual.SocialPost::hasGoneOut(), not the
published state.SocialPost without a moment, nor approve on a drag. The calendar shows every post
at its plannedAt, and the planned run is the only clock; a drag moves, "Valider" approves.SocialImageExporter::jpegUrl().share_buttons_display cacheable. Its render carries the current page's url, so the
first page's share links would be served on every other page holding that block.
social_links_display is the opposite case and is cached, the singleton's own tag added on top of
its own - see Service\SocialBlockCacheTagProvider.How can I help you explore Laravel packages today?