A teacher's open classroom for the sites built on omnisense/omnibase:
what a colleague passing by may pick up, organised the way the French teachers' sites are - by
level, by subject, by purpose - and built on omnibase's own Thread and Taxon instead of a
blog engine. It is a Symfony bundle (Base\Classroom\ClassroomBundle) on the core 3 or 4, with
its back office on omnibase/admin; with omnibase/marketplace it sells resources, with
omnibase/consent its frames wait for the visitor's yes.
| Entity | Is a | What |
|---|---|---|
Level |
Taxon |
a class level, in a tree of cycles: Cycle 2 › CP, CE1, CE2 |
Subject |
Taxon |
a discipline and its domains: Français › Lecture, Grammaire |
Purpose |
Taxon |
"pour la classe": rituals, displays, autonomy, games |
Sequence |
Thread |
a learning sequence: objectives, competences, period (P1-P5), material, cover, resources, and its sessions |
Session |
Thread, child of a sequence |
one session: its place, duration, aim, material and course |
Card |
Thread |
an idea card: a picture, a few lines, one to three links - the home page's fan |
Resource |
- | a file to download (PDF, document, spreadsheet, archive): free, for members, or paid |
Product\ResourceOffer |
omnibase/marketplace Product |
a paid resource for sale |
Entitlement |
- | the right to download a paid resource, written when its order is paid |
Levels, subjects and purposes are the threads' taxa, so a sequence is "CP and CE1 · Allemand"
and every list filters by them. The text of a sequence, a session or a card is EditorJS,
autosaved, co-edited live when the core's relay runs. Resources live outside public/ and are
served through signed, short-lived links.
How this manual is organised
- Installation: requirements, the bundle, the routes, the storage, the layout.
- Levels, sequences, cards and resources: every entity and its fields, the queries.
- The pages: every route and what it lists, the search.
- Downloads and embeds: who may download, the signed links, nginx, the spreadsheet preview, the frames and the consent.
- The shop: selling a resource, the quick order, entitlements, the mail.
- In a host page: the Twig functions and filters, the templates, the translations.
- The back office: the screens, who writes, the tile.
- Configuration reference:
classroom:, the services. - Testing.
Proposer une modification docs/index.md
Installation
Requirements
What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3,
omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin,
phpoffice/phpspreadsheet 1.23 to 5, and symfony/config, dependency-injection, form,
http-kernel and security-bundle 6, 7 or 8. It suggests omnibase/marketplace (selling
resources) and omnibase/blog (comments under a sequence); it works with omnibase/consent when
the site has it.
The bundle
// config/bundles.php
return [
// ...
Base\BaseBundle::class => ['all' => true],
Base\Admin\AdminBundle::class => ['all' => true],
Base\Classroom\ClassroomBundle::class => ['all' => true],
];
ClassroomBundle aliases Base\Classroom\Entity\* to App\Entity\Classroom\* and the
repositories to App\Repository\Classroom\* (unless the application declares its own), and
registers Compiler\EmbedsConsentPass. Its templates/ is @Classroom, its public/
bundles/classroom/.
The routes
# config/routes.yaml
classroom_controller:
resource: "@ClassroomBundle/src/Controller/Client"
type: attribute
prefix: /
The paths are French (The pages); /boutique exists only with
omnibase/marketplace's quick order.
The storage
The files go to the flysystem storage local.classroom (the Resource::$file uploader), which
the host defines, outside public/ (classroom.storage_dir is not read: the storage's own
directory is what counts):
# config/packages/flysystem.yaml
flysystem:
storages:
local.classroom:
local:
directory: '%kernel.project_dir%/var/storage/classroom'
Configuration
# config/packages/classroom.yaml (every key optional)
classroom:
accel_prefix: ''
download_ttl: 600
preview_rows: 30
members_role: ROLE_USER
per_page: 24
from_email: null
The schema and the public files
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
bin/console assets:install
Tables classroom_sequence, classroom_session, classroom_card (joined to the core's thread
table), classroom_resource, classroom_resource_taxon, classroom_sequence_resource,
classroom_entitlement, and the taxa's discriminator entries; the stylesheet
bundles/classroom/css/classroom.css.
What the layout provides
The pages extend layout1.html.twig and fill its title, description, content and
stylesheets blocks.
Proposer une modification docs/installation.md
Levels, sequences, cards and resources
The taxa
Level, Subject and Purpose extend the core's Base\Entity\Thread\Taxon (a translated
label and description, an icon, a slug, a parent and children):
| Taxon | Discriminator | Adds |
|---|---|---|
Level |
classroom_level |
position (youngest first), short ("CP"; the label when empty), color; isCycle() - no parent: a cycle holds levels |
Subject |
classroom_subject |
position, color (the parent's when empty) |
Purpose |
classroom_purpose |
position |
The threads (sequences, cards) and the resources keep their levels, subjects and purposes in one
association, taxa, read and written by kind (ClassifiedTrait, TaxaOfKindTrait):
getLevels() (by position), getSubjects(), getPurposes(), and setLevels(),
setSubjects(), setPurposes(), each replacing the taxa of its kind and leaving the others; a
value of another kind is ignored. isMultiLevel(), getLevelsLabel() ("CP · CE1").
Sequence
Extends Thread (classroom_sequence); title, headline, excerpt, the presentation (content),
slug, state and publishedAt are the thread's.
| Property | Column | Meaning |
|---|---|---|
periodValue |
period, string(2), nullable |
Period: p1 to p5, the five periods of the French school year; getPeriod() |
durationMinutes |
integer, nullable |
the whole sequence; null: the sum of its sessions |
objectives, competences |
json |
lists; …Text accessors take one per line |
materials |
text |
|
cover |
upload, image, 8 MB | |
featured |
boolean |
first among the latest |
resources |
many-to-many Resource (classroom_sequence_resource) |
the files to download |
embeds |
json |
what it frames: {label, url}; embedsText takes "Label | https://..." per line (only http(s) addresses kept) |
getSessions() - its children that are sessions, by position then id.
Session
Extends Thread (classroom_session), a child of its sequence (setSequence() sets the
parent): position (1 at least), durationMinutes, materials (a list, one per line). Its
headline is its aim, its text the course. Its link is the sequence's page at #seance-<position>.
A session created in the back office is published.
Card
Extends Thread (classroom_card): image (8 MB), links (up to the author: {label, url},
"label | url" per line in linksText; a line without label is its own label), featured (on
the fan), position, icon (a Font Awesome class, big on a card without picture, a badge on one
with), color, embeds, and its levels and subjects.
Resource
classroom_resource:
| Property | Meaning |
|---|---|
title (not blank), slug (unique, from the title), description |
|
file |
the upload on the private storage local.classroom, 64 MB: PDF, zip, Office and OpenDocument files, CSV and the like |
filename |
its name when saved ("kangourou-ce1.xlsx"); else the slug and the extension |
mimeType, size |
kept when the file is saved from the back office |
preview |
a picture of it, on the public storage, 8 MB |
visibilityValue |
visibility column: free, members, paid (Visibility); getVisibility(), isFree(), isPaid() |
published |
true |
taxa |
its levels and subjects (classroom_resource_taxon) |
sequences |
the sequences it belongs to |
downloads |
counted at each full download |
owner, createdAt, updatedAt |
getExtension() (from the name, else from the type), isSpreadsheet() (xlsx, xls, xlsm, csv,
ods), hasFile().
Entitlement and ResourceOffer
Entitlement (classroom_entitlement, unique on user and resource): the buyer, the resource,
the order's reference, downloads, createdAt. It never expires.
Product\ResourceOffer extends omnibase/marketplace's Product (discriminator
classroom_resource_offer): its resource; not shippable; one at most per order.
Repositories
| Repository | Methods |
|---|---|
SequenceRepository |
findPublishedPage(int $page, int $perPage, array $taxa = [], ?string $period = null) (newest first), countPublished(), findLatest(int $limit = 3) (featured first), findOnePublished(), search() (title, excerpt, text), countDrafts() |
CardRepository |
findFeatured(int $limit = 9) (the fan: featured first, by position, newest), findPublished(array $taxa = [], int $limit = 200), findOnePublished(), search() (title, headline, text) |
ResourceRepository |
findPublished(array $taxa = [], int $limit = 200) (newest first), findOnePublished(), search() (title, description), sumDownloads() |
EntitlementRepository |
findForUser(), findOne(?object $user, Resource), countSince() |
$taxa are slugs, all required. A published thread is PUBLISH with a publishedAt past or
none. Base\Classroom\Service\Taxa: cycles(), levels() (the levels alone), subjects(),
purposes() (the top ones, in order), and one by slug: level(), subject(), purpose().
Proposer une modification docs/concepts.md
The pages
| Route | Path | Sitemap | Page |
|---|---|---|---|
classroom_index |
/classe |
0.8, weekly | the cycles, subjects and purposes, six latest sequences, six cards of the fan |
classroom_level |
/classe/{level} or /classe/{level}/{subject} |
a level, and a subject | |
classroom_subject |
/matiere/{subject} |
a subject | |
classroom_purpose |
/pour-la-classe/{purpose} |
a purpose | |
classroom_sequences |
/sequences |
0.7, weekly | the sequences alone |
classroom_sequence |
/sequences/{slug} |
a sequence, its sessions, four related ones of its subjects | |
classroom_cards |
/cartes |
0.7, weekly | the cards alone |
classroom_card |
/cartes/{slug} |
a card, the previous and the next of the fan | |
classroom_resources |
/ressources |
0.7, weekly | the resources alone |
classroom_resource |
/ressources/{slug} |
a resource, its preview, its offer when for sale | |
classroom_download |
/ressources/{slug}/telecharger |
the download's first step | |
classroom_download_file |
/ressources/fichier/{id}/{filename} |
the signed file | |
classroom_search |
/rechercher?q= |
sequences, cards and resources holding the term (3 characters at least) | |
classroom_shop |
/boutique |
the resources for sale (The shop) |
An unknown slug is a 404.
The listings
The level, subject, purpose, sequences, cards and resources pages are one listing
(@Classroom/client/listing.html.twig), filtered by the taxa in the path (all required):
| Query | |
|---|---|
?periode=p1 ... p5 |
the sequences of that period |
?voir=all, sequences, cards, resources |
what is shown; all (the taxa pages) shows the sequences of the page, 8 cards and 8 resources |
?page= |
the sequences' page, classroom.per_page (24) a page |
The page gets the level, subject and purpose chosen, every level, subject and purpose (the pills), the periods and the counts for the pager.
Proposer une modification docs/usage-pages.md
Downloads and embeds
Signed downloads
A resource's file is downloaded through a signed link (omnisense/omnibase's
Base\Service\DownloadLinks): ResourceController signs
classroom_download_file for classroom.download_ttl seconds once
ResourceVoter let the visitor through, and checks the signature when the file
is asked for; after a quick order, QuickOrderFilesListener signs a link for a
day. The bundle no longer has a DownloadLinks of its own.
Who may download
Base\Classroom\Security\ResourceVoter decides CLASSROOM_DOWNLOAD on a Resource: an
unpublished one only for ROLE_ADMIN; then a free one for anyone, a members' one for
classroom.members_role (ROLE_USER), a paid one for ROLE_ADMIN or whoever holds an
Entitlement to it. classroom_may_download(resource) asks it in Twig.
The download, in two steps
/ressources/{slug}/telecharger(classroom_download): a resource without file is a 404; refused, an anonymous visitor goes to the sign-in (back here after), a signed-in one back to the resource's page with the flash "paid only" or "members only"; allowed, a redirect toclassroom_download_filesigned forclassroom.download_ttlseconds (with the entitlement's id, when there is one)./ressources/fichier/{id}/{filename}(classroom_download_file): an invalid or expired signature is a plain 403 (signing in would not help); a file missing from the storage a 404. A GET withoutRangecounts one download on the resource and on the entitlement. The file is sent as an attachment under its saved name - by nginx whenclassroom.accel_prefixis set (X-Accel-Redirect: <prefix>/<path under the storage>), else by PHP (BinaryFileResponse, private).
The spreadsheet preview
Base\Classroom\Service\SpreadsheetPreview::sheets(Resource): for a spreadsheet (xlsx, xls,
xlsm, ods through PhpSpreadsheet, data only; csv read directly - semicolons when the first line
has more of them than commas), the first classroom.preview_rows (30) rows of each sheet, empty
rows dropped, with whether there are more; cached a day, until the resource changes. A file that
cannot be read gives nothing.
What a sequence or a card frames
The "Intégrations" of a sequence or a card (one address per line, "Label |
https://…") are framed by omnisense/omnibase's Twig function embed_url(): Canva,
Padlet, YouTube, Vimeo, LearningApps, Genially and Google Slides are read from
the address; any other site is asked for its player (embed/embed, oEmbed) and
framed when it has one, a plain link otherwise. classroom_embed() and
Base\Classroom\Service\Embeds are gone; @Classroom/client/_embeds.html.twig
uses embed_url() (embed.open.other labels a provider not known by name).
Behind the cookie answer
A frame brings its provider's cookies (Canva, Padlet, YouTube, Vimeo...). On a
site with omnibase/consent, it waits for the visitor's yes to the EMBEDS
feature - the name omnibase/video's platform frames use: the iframe carries
its address in data-consent-src and has none until then, and a sentence
(embed.consent.text, the provider's name) stands in its place with a button
that opens the detailed section of the cookie panel (data-consent-open="details"),
where EMBEDS is listed; the link to the provider's page stays under the
frame. Accepted, the frame loads; refused again, it is emptied.
Compiler\EmbedsConsentPass adds the feature to the ones every site starts
from (omnibase/consent's consent.defaults) - on demand: listed on the pages
that frame something, never a reason to ask a visitor again - with what these
providers were seen to keep on their own domain (Canva's CDI and CL,
Vimeo's vuid, the YouTube player's yt-remote-*); its words are
translations/consent+intl-icu.*.yaml (features.embeds.*,
storage.embeds_*). A site lays its own consent.features.EMBEDS over it,
field by field (declared there, it is listed on every page unless it says
on_demand: true), and its own words over the bundle's; defaults: false
leaves it out. It is not put in the site's consent: configuration, whose
features are the site's own list (data-consent-groups). Without
omnibase/consent the frames load with the page (classroom_consent() is null).
Proposer une modification docs/downloads-and-embeds.md
The shop
With omnibase/marketplace, a paid resource is sold as a Product\ResourceOffer.
Putting a resource on sale
The resource's Mettre en vente (/admin/resources/{entityId}/sell) finds its offer, or makes
one in the first store - titled and described as the resource, a draft - and sets the resource
paid; then opens the offer's screen (ResourceOfferCrudController, omnibase/marketplace's
product screen plus the resource) for its price and publication.
Selling
/boutique (classroom_shop, loaded only with omnibase/marketplace's QuickOrder) lists the
offers for sale whose resource is published and has a file, newest first, sold with the
marketplace's quick order: an e-mail address, the payment, no account, no basket. A signed-in
member buys in their own name.
Delivering
EventSubscriber\OrderPaidSubscriber(onOrderPaidEvent): for each item of the order that is a resource offer, anEntitlementfor the customer (one per resource and buyer: a webhook and a redirect confirming the same order write it once); then a mail (@Classroom/email/files.html.twig, fromclassroom.from_email) with the link of the order's page (QuickOrder::doneUrl()). An offer without resource is logged as an error; a mail that fails is logged, never a reason to undo the confirmation.EventSubscriber\QuickOrderFilesListener(onQuickOrderDoneEvent): the quick order's page, reached by its own signed link, renders@Classroom/client/thanks.html.twigwith the files - once paid, each behind a signed download link valid a day.
Both are loaded only with omnibase/marketplace (and its quick order for the second).
Proposer une modification docs/usage-shop.md
In a host page
Twig functions and filters
Base\Classroom\Twig\ClassroomExtension:
| Returns | |
|---|---|
classroom_cycles() |
the cycles, each holding its levels |
classroom_levels() |
the levels alone, in order |
classroom_subjects(), classroom_purposes() |
the top ones, in order |
classroom_cards(limit = 9) |
the fan |
classroom_sequences(limit = 3) |
the latest sequences, featured first |
classroom_resources(limit = 6) |
the latest resources |
classroom_consent() |
EMBEDS when the site has omnibase/consent, else null (the frames load at once) |
classroom_may_download(resource) |
the voter's answer |
x|classroom_size |
bytes in French units: 2,4 Mo |
x|classroom_minutes |
45 min, 1 h 30 |
Templates
@Classroom/client/: index, listing, sequence, card, resource, search, shop,
thanks, and the partials _card, _sequence_card, _resource_card, _pills, _embeds;
@Classroom/email/files; @Classroom/admin/widget/overview. An application overrides one in
templates/bundles/ClassroomBundle/....
Translations
The domains classroom (the pages, the back office's words), entities (the screens' names) and
consent (the EMBEDS feature's words for omnibase/consent's panel), ICU, in French, English and
German.
Proposer une modification docs/usage-twig.md
The back office
Loaded when omnibase/admin is installed. The screens open creating, editing and deleting to
ROLE_ADMIN (OpenToAdminsTrait: a teacher writes, not only the super-administrator).
| CRUD | Slug | Fields |
|---|---|---|
SequenceCrudController |
sequences |
title, state, slug (following the title), publication date, featured, levels, subjects, purposes (one picker per kind), period, duration, headline, excerpt, objectives and competences (one per line), material, the presentation (EditorJS, autosaved and live), embeds, resources, cover; filters state, period, featured |
SessionCrudController |
sessions |
sequence, position, duration, state, title, slug, aim, material, course; filter sequence |
CardCrudController |
cards |
title, state, featured, position, slug, headline, levels, subjects, icon, colour, text, links, embeds, picture; filters state, featured |
ResourceCrudController |
resources |
title, visibility, published, slug, file, saved name, description, levels, subjects, preview, downloads (list); filters visibility, published; Mettre en vente with omnibase/marketplace |
ResourceOfferCrudController |
resource-offers |
the marketplace's product screen and the resource (with omnibase/marketplace) |
LevelCrudController |
levels |
omnibase/admin's taxon screen, plus position, short name, colour |
SubjectCrudController |
subjects |
the taxon screen, plus position, colour |
PurposeCrudController |
purposes |
the taxon screen, plus position |
A sequence, a card or a resource created here has the signed-in user as owner. Saving a resource keeps its file's size, type and - when empty - its name. Only what a record cannot do without is required (a title, a state, a slug; a resource's file, a session's sequence).
The tile classroom_overview (ClassroomOverviewWidgetType): sequences online and in draft,
cards online, resources published, downloads, entitlements bought since the first of the month,
each linking to its screen.
Proposer une modification docs/back-office.md
Configuration reference
classroom:
Base\Classroom\DependencyInjection\ClassroomConfiguration, dumped with Symfony's
YamlReferenceDumper in omnisense/omnibase's harness image:
classroom:
# Where the resources are (the local.classroom flysystem storage points there).
storage_dir: '%kernel.project_dir%/var/storage/classroom'
# nginx internal location streaming the files (X-Accel-Redirect); empty: PHP streams them.
accel_prefix: ''
# Seconds a signed download link stays valid.
download_ttl: 600
# Rows of a spreadsheet shown in a resource's preview.
preview_rows: 30
# Who the MEMBERS resources are for.
members_role: ROLE_USER
per_page: 24
# Sender of the mail a paid order gets (the link to its files); null: the mailer's default sender.
from_email: null
| Option | Type | Default | Read by |
|---|---|---|---|
storage_dir |
path | %kernel.project_dir%/var/storage/classroom |
nothing (see below) |
accel_prefix |
string | '' |
ResourceController |
download_ttl |
int, 30 or more | 600 |
ResourceController |
preview_rows |
int, 1 or more | 30 |
SpreadsheetPreview |
members_role |
role | ROLE_USER |
ResourceVoter |
per_page |
int, 1 or more | 24 |
ClassroomController |
from_email |
string or null | null |
OrderPaidSubscriber |
storage_dir is deprecated: it was read by nothing. The files go to the flysystem storage
local.classroom, whose directory the site sets in its flysystem.yaml; a site that still sets
storage_dir gets Symfony's deprecation notice.
The extension also sets classroom.consent: whether the container has omnibase/consent's
extension.
Services
config/services.php autowires and autoconfigures src/ but DependencyInjection/, Entity/,
Enum/, Controller/Admin/, Admin/, EventSubscriber/ and the bundle class;
Controller/Client/ is tagged controller.service_arguments (ShopController only with
omnibase/marketplace's QuickOrder); with omnibase/admin, Controller/Admin/
(ResourceOfferCrudController only with omnibase/marketplace's Product) and Admin/; with
omnibase/marketplace's OrderPaidEvent, EventSubscriber/ (QuickOrderFilesListener only with
its QuickOrderDoneEvent).
Compiler\EmbedsConsentPass adds EMBEDS to omnibase/consent's consent.defaults: on demand,
with the storage these providers keep - Canva's CDI (a year) and CL (60 days), Vimeo's
vuid (two years), the YouTube player's yt-remote-* (local storage). Another bundle's
EMBEDS keeps its fields; the entries it does not name are added.
Proposer une modification docs/configuration.md
Testing
vendor/bin/phpunit
The tests are units. In omnisense/omnibase's harness, with this checkout and omnibase/consent's:
docker run --rm --network none \
-v "$PWD":/srv/demo/vendor/omnibase/classroom:ro -v "$PWD/../consent":/srv/demo/vendor/omnibase/consent:ro \
omnisense/omnibase-harness test classroom
Run on 2026-10-27 with both at HEAD: 27 tests, 1 error.
EmbedsConsentTest takes omnibase/consent's own features from ConsentPanel::features(), not
from a path guessed beside a class's file.
| File | What |
|---|---|
DependencyInjection/EmbedsConsentTest |
without omnibase/consent the frames load at once; with it EMBEDS is one of the features every site starts from; the panel lists EMBEDS with the bundle's words |
Templates/EmbedsTemplateTest |
with consent no frame has an address before the yes; without, the frames load with the page; the placeholder speaks every language; every language has every key |
Entity/TaxaOfKindTest |
a card's levels replaced, its subjects kept; a sequence's three kinds written apart; what is not of the kind ignored; a resource the same way |
Controller/CrudFieldsTest, Controller/CrudRequiredFieldsTest |
one field per property, written through the entity; the slug follows the title; each kind of taxa has its picker; only what the record needs is required |
Controller/PeriodFieldTest |
a new sequence shows no period; sent empty it stays empty; a period chosen is recorded |
Controller/AdminWordsTest |
every screen named in every language; every entity of a screen listed; every key the screens give translated |
Saving through the back office is a host's functional test (a site's own).