c975l / card-bundle
Symfony bundle for collectible cards on the c975L core — what each player holds and in which finish, kept on the device before any account exists, card backs to win or choose, and the plumbing of the mobile app's API. The catalogue stays the site's own.
Requires
- php: >=8.4
- c975l/core-bundle: ^1.82
- c975l/payment-bundle: ^6.22
- doctrine/dbal: ^4.4
- doctrine/orm: ^3.7
- easycorp/easyadmin-bundle: ^5.7
- symfony/console: ^8.0
- symfony/filesystem: ^8.0
- symfony/framework-bundle: ^8.0
- symfony/http-foundation: ^8.0
- symfony/messenger: ^8.0
- symfony/process: ^8.0
- symfony/rate-limiter: ^8.1
- symfony/routing: ^8.0
- symfony/security-bundle: ^8.0
- symfony/translation: ^8.0
- symfony/twig-bridge: ^8.0
- symfony/validator: ^8.0
- vich/uploader-bundle: ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpstan/phpstan-deprecation-rules: ^2.0
- phpunit/phpunit: ^13.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Symfony bundle for collectible cards on the c975L core — what each player holds and in which finish, kept on the device before any account exists, card backs to win or choose, and the plumbing of the mobile app's API. The catalogue stays the site's own.
Bundle page · Tutorials · Live demo · Demo back-office
BUNDLE UNDER DEVELOPMENT — USE AT YOUR OWN RISK
Why CardBundle
Add CardBundle on top of the shared CoreBundle foundation when a site gives cards to collect — its own catalogue, whatever it is made of. The bundle holds the collection, never the cards: a card is named by its reference, so a site whose cards are its existing pages (the fiches of a universe) and a site with a catalogue of its own both plug in without a row being copied.
TL;DR —
CardCollector::grant()gives a card in a finish,CardHolder::resolve()says whose collection a call is about (the device's token, then the account it is connected to),BackCollectorgives and chooses card backs,CardRequestis the rate limit, token and private answer every app call starts with. The routes and their JSON stay the site's.
Contents
- Setup — requirements · installation
- Using it — the collection · card backs · the app's api · exports · what the site plugs in · translations · guided projects · what it does not contribute · AI agent skills
Features
- A collection held under a key rather than an account: the token a device made up, then
u<id>once a member connects it — a player collects on the first evening, before anyone signs up, and nothing is lost when they do - Three finishes (
CardFinish: normal ●, shiny ◆, magic ✦), the finish being no other card; the ladder (shiny only over normal) is offered, never imposed - Card backs: variants of a pack's back, with a picture, a tone and an effect, given by a reward, a purchase or a code, and chosen one per pack
- Card backs for sale, in the app stores and on the site, through PaymentBundle (
InAppProductProviderInterfaceandBasketItemProviderInterface), at one price - A device's collection handed over to the account on its first connected call, the account keeping its own where both hold the same card
- Everything erased with the account (ConfigBundle's
UserAnonymizedEvent), the site's own stores included - Every card and back in the member's data export (ConfigBundle's
/account/export, throughAccountDataProviderInterface) - One export of the cards, paper and social networks alike, that only redoes what changed since the last run (
card:export, or a dashboard tile where Chrome is installed) - One back-office screen contributed to the EasyAdmin dashboard (
MenuProviderInterface): the card backs - Guided projects (
GuidedProjectProviderInterface): making a card back, exporting the cards - A demo dataset (UiBundle's
DemoFixtureProviderInterface): three card backs over the site's first two packs, each illustrated by its own placeholder when the site declares one underkeyed_images['card/<slug>'](demo-dawn,demo-storm,demo-night), one of the generic pool otherwise — no catalogue and no collection, both being a site's own - Its own
cardtranslation catalogue, in English, French and Spanish - A skill for coding agents, shipped in the package and read straight from
vendor/— see AI agent skills
Requirements
- PHP >= 8.4, Symfony 8
- c975L/CoreBundle — ConfigBundle and UiBundle in one package: user accounts, the dashboard, the media uploads, the device token the favorites already use
Installation
Download
composer require c975l/card-bundle
Run migrations
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate
Three tables: card_collected, card_back and card_collected_back. No foreign key ties a holding to an account: the key is u<id>, erased by the bundle when the account goes.
Nothing to register by hand
No route to import, no config key: the menu entry, the guided projects, the account export, the anonymization listener and the site's own implementations (see what the site plugs in) are picked up by autoconfiguration. A back's picture goes through UiBundle's block_media upload mapping, under public/medias/cards/backs/.
The collection
use c975L\CardBundle\Enum\CardFinish; use c975L\CardBundle\Service\CardCollector; use c975L\CardBundle\Service\CardHolder; $holder = $this->cardHolder->resolve($token); // null: neither an account nor a usable token $collected = $this->cardCollector->grant($holder, 'GUE 02', CardFinish::Shiny, 'quiz'); // null when that finish is held already, a gift sent twice at once included
Which card and which finish is the site's decision, taken before calling: a site giving a finish only over the one below checks CardFinish::requires() against CollectedCardRepository::finishesByReference() itself. The source ('quiz', 'draw', 'app') is the site's own word, kept so a collection can tell its ways in apart. The same gift sent twice at once is refused by the unique index rather than answered with a 500, but Doctrine then closes the EntityManager: nothing else is written in that request.
CardHolder::ofUser($user) is the key of an account, CardHolder::isAccount() whether the caller is signed in to a saved account.
Card backs
A CardBack belongs to a pack — the site's own key: a castle's prefix, a set's slug — and only a published one is ever given. BackCollector::grant($holder, $back, $source) gives it, BackCollector::choose($holder, $pack, $back) makes it the one that pack's cards wear (null for the pack's own picture), refusing a back not held, unpublished or of another pack, and BackCollector::chosen($holder) lists the choices by pack. Unpublishing a back withdraws it: it is no longer worn, the choice being kept so that republishing it puts it back. A back's pack and slug are set on creation and locked afterwards, the slug being lowercase words joined by hyphens, and a pack's backs are read by position, those given none last. How a tone or an effect is drawn is the site's stylesheet and its app's: the bundle only names them (BackTone, BackEffect) and refuses the two drawn on the same layer. The back office lists each back by its uploaded picture; a site drawing its backs there as its app does overrides templates/bundles/c975LCardBundle/management/field_card_back.html.twig.
Selling a back
A back given a price (in cents, VAT included, with its own VAT rate, none under the VAT franchise) and published is on sale, at that one price in the app and on the site: 1,00 € and 2,00 € are both price points of the stores. Its store product id is derived from its slug, back.<slug> with underscores for hyphens (CardBack::getProductId(), see PaymentBundle's InAppProduct::idFor()), never typed, which is why the slug never changes once created. A purchase is owed: BackCollector::grant(..., paid: true) gives a bought back even one unpublished since, and a basket paid for a back the account earned in the meantime is logged as a warning for the admin to refund.
- In the app,
BackInAppProductProvideris PaymentBundle'sInAppProductProviderInterface:c975l:payment:store:publishcreates the products from it, named « pack - variant » in each language (the variant alone past Apple's 30 characters), and a verified purchase gives the back throughBackCollector::grant()under the store's name,appleorgoogle. A refund takes it back throughBackCollector::revoke(), which removes only what that store gave: the same back won in a quest stays. The site's own app endpoints still ask for the account token and send the proofs (see PaymentBundle's selling in a mobile app). - On the site,
BackBasketItemProviderputs a back in PaymentBundle's basket under thecardbackkind: once per back, refused when the account already holds it, the visitor asked to sign in at the checkout (AccountBasketItemProviderInterface). It is digital content supplied at once (Basket::CONTENT_FLAG_DIGITAL), which asks for the waiver of the right of withdrawal, and once paid it is given asshop. Where the site shows the button is its own: a back is bought where its collection shows it, adding{kind: cardback, id}to the basket.
The app's api
The routes and their JSON stay in the site's controllers — an app's api is what it is (an album and its Plumes, a collection and its packs) and its payload is what the app was built against. What every call shares is CardRequest:
use c975L\CardBundle\Service\CardRequest; #[Route('/app/api/cards/collection', methods: ['POST'])] public function collection(Request $request, CardRequest $cardRequest): Response { $holder = $cardRequest->holder($request); // a 429 Response over UiBundle's favorites limit if ($holder instanceof Response) { return $holder; } return CardRequest::private(new JsonResponse($this->presenter->collection($holder))); }
CardRequest::body($request) is the json body, empty when it does not parse.
Exports
One export, php bin/console card:export [target] [--force] or the dashboard tile (shown only where Chrome and Ghostscript are installed — the local machine, not a managed server — and run by the Messenger worker). The site hands its pages over through CardExportSourceInterface, rendered by its own Twig under its own stylesheets, the bundle only shooting them:
PdfItem($path, $html, $faces): a document Chrome prints to PDF — a booklet, a plate, a series of printer-ready cards — and, with faces, one image per page at 600 dpi (Ghostscript) in the folder named after it, a face leftnullbeing a page droppedSocialItem($path, $postHtml, $reelHtml, $manifest, $entry): one card in one finish, a 1080x1350 image and a 7-second 1080x1920 video (Chrome held open, ffmpeg), the page exposingstill()andframe(t); the manifest lists every card for the posts' drafts
PageRenderer gives the source what a page opened from disk needs: stylesheets($paths, $screen) (urls put onto the disk, the print rules dropped on screen) and onDisk($html) (images loaded eagerly, site and AssetMapper paths put onto the disk).
It is idempotent: each item's fingerprint — its pages, and the date and size of every file they read — is kept in Construction/card-export.json, and an item whose fingerprint has not moved, its files still there, is left as it is. The first run shoots everything; the next one only what was edited. A full run also removes the files of what the site no longer exports; a target (read by the site: a series, a card) never removes anything. The site routes c975L\CardBundle\Message\ExportCardsMessage to its async transport, or the tile runs inside the request, with no time limit. Only one run at a time: a second, from the tile or the terminal, is refused while one is under way.
What the site plugs in
Four interfaces, each tagged by autoconfiguration — implementing one is all it takes:
| Interface | What for | Without it |
|---|---|---|
HolderStoreInterface |
whatever else the site keeps under a holder's key (a child's Plumes), moved and erased with the cards | only the cards and the backs move |
CardCatalogInterface |
the name of a card, from its reference | the reference is shown |
CardPackProviderInterface |
the packs a back can be made for | the pack is typed in the back office |
CardExportSourceInterface |
the pages to export, paper and social (see exports) | no tile, card:export has nothing to do |
Translations
Everything the back office shows is in the card domain, its spoken narrations in card_narration, both in English, French and Spanish.
Guided projects
The bundle contributes two guided projects, in the 11000 block GuidedProjectProviderInterface reserves it. card-back makes a variant of a pack's back: it opens on the card backs' listing, then walks the form field by field — the pack, the name, the picture, the tone, the effect, the price and its VAT when it is for sale — then the box that publishes it, a back being given only once published, and ends on the save button. It is offered to site-role-editor, the role the screen itself asks for.
card-export opens on the dashboard, highlights the export tile, then tells how to follow the run on the tile. Like the tile, it is offered only where the export can run, to ROLE_SUPER_ADMIN.
The ways a back is then had — a reward, a purchase, a code — are not walked: a reward and a code are the site's own code calling BackCollector::grant(), a purchase PaymentBundle's, with nothing to do in the back office beyond the price.
What this bundle deliberately does not contribute
ConfigBundle and UiBundle expose a long list of contribution points, and not branching one is a valid answer — it is only worth writing down:
| Point | Why not |
|---|---|
Config keys (configs.json) |
it reads only the core's site-role-editor, and the limit is UiBundle's favorites one |
"What's new" (whatsnew.json) |
prose to be written when the bundle is actually deployed somewhere, not before — its guided projects are written, see above |
| Maintenance task | nothing here runs on a schedule: a card is given by the site's own call, and a collection only goes with its account |
| The catalogue | the cards are the site's own entities, named here by reference |
| Routes | an app's api is the site's own: its payload is what the app was built against — the only route is the export tile's |
| Account section | "My collection" counts and links to the site's own collection page |
| Blocks | a collection is shown by the site's own pages and app |
| Emails, sitemap, feeds | no public page and nothing to announce of its own |
| Content translation | a back's name is short and the site's; translated catalogues are the site's |
| Content import / export | a collection is what players did, not content |
AI agent skills
The package ships a skill of its own, skills/c975l-card/SKILL.md, written for the coding agent of the site installing this bundle rather than for someone modifying it. Point your agent at it:
vendor/c975l/card-bundle/skills/
It holds what an agent gets wrong when left to its own habits — that a shiny card is the same card in another finish and never a catalogue entry of its own, that a holding is tied to no account by a foreign key, that a device's collection moves on every call rather than in a login listener, that what else a site keeps under the holder's key implements HolderStoreInterface instead of a second listener, and that the stored values of the enums and an app's routes never change — alongside the services, the entities and the interfaces, each named as it actually is in the sources.
Nothing is installed, nothing is copied into your project: the file sits in vendor/ like any other part of the package and follows it at each composer update. A user of Claude Code wanting it to load by itself symlinks it into their own skills directory:
ln -s ../../vendor/c975l/card-bundle/skills/c975l-card .claude/skills/c975l-card
Tests\SkillsTest keeps the file honest: every path, route, config slug, command, class member, Twig function, block kind and component it quotes is checked against the sources, so renaming any of them fails the build rather than leaving an agent confidently wrong.
Tip
If this project helps you save development time:
- star it on GitHub — helps others find it
- open an issue to share how you use it — genuinely useful feedback
And if you'd like to support the work directly, the Sponsor button at the top of the GitHub page is there for that. Thank you!