Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
c975l-social
c975l/social-bundle
claude-code
symfony
bundle
social

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.

Install
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`.

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

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.

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

{# 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).

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.

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:

# 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings