This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
These rules apply to every task in this project unless explicitly overridden. Bias: caution over speed on non-trivial work. Use judgment on trivial tasks.
State assumptions explicitly. If uncertain, ask rather than guess. Present multiple interpretations when ambiguity exists. Push back when a simpler approach exists. Stop when confused. Name what's unclear.
Minimum code that solves the problem. Nothing speculative. No features beyond what was asked. No abstractions for single-use code. Test: would a senior engineer say this is overcomplicated? If yes, simplify.
Touch only what you must. Clean up only your own mess. Don't "improve" adjacent code, comments, or formatting. Don't refactor what isn't broken. Match existing style.
Define success criteria. Loop until verified. Don't follow steps. Define success and iterate. Strong success criteria let you loop independently.
Use Claude for: classification, drafting, summarization, extraction. Do NOT use Claude for: routing, retries, deterministic transforms. If code can answer, code answers.
Per-task: 20,000 tokens. Per-session: 100,000 tokens. If approaching budget, summarize and start fresh. Surface the breach. Do not silently overrun.
If two patterns contradict, pick one (more recent / more tested). Explain why. Flag the other for cleanup. Don't blend conflicting patterns.
Before adding code, read exports, immediate callers, shared utilities. "Looks orthogonal" is dangerous. If unsure why code is structured a way, ask.
Tests must encode WHY behavior matters, not just WHAT it does. A test that can't fail when business logic changes is wrong.
Summarize what was done, what's verified, what's left. Don't continue from a state you can't describe back. If you lose track, stop and restate.
Conformance > taste inside the codebase. If you genuinely think a convention is harmful, surface it. Don't fork silently.
"Completed" is wrong if anything was skipped silently. "Tests pass" is wrong if any were skipped. Default to surfacing uncertainty, not hiding it.
Review every new and modified file (controller, Vue component, updated legacy links) for common vulnerabilities. This step must be re-run after any subsequent change request (e.g., adding features, fixing bugs, updating links) — not only on the initial pass.
- CSRF on state-changing endpoints. Any POST/PUT/DELETE controller that performs destructive or sensitive actions (delete, copy, anonymize, restore, status toggle, etc.) must validate a CSRF token. Use
$this->isCsrfTokenValid('intent_name', $token)in the controller. Generate the token viaCsrfTokenManagerInterface::getToken('intent_name'), return it in the data endpoint JSON, and include it as a hidden_tokenfield in the Vue form submission. - Broken access control. Verify that
#[IsGranted(...)]on the controller matches the legacy page's access checks. If the legacy page allowed both admins and session admins, use theExpressionform. Verify that every destructive action inside the controller re-checks the role (e.g., session admins should not be able to delete sessions they don't manage just because they can reach the endpoint). For non-admin roles, filter actionable entity IDs to only those the current user is authorized to manage. - SQL injection. Never interpolate user input into DQL/SQL strings. Always use bound parameters (
:paramName+setParameter()). The QueryBuilder already does this — verify no raw concatenation slipped in. Sort field values must use an allowlist mapping, never be passed directly intoorderBy(). - XSS. Vue's template syntax (
{{ }}) auto-escapes by default. Verify nov-htmlis used with user-supplied data. If linking to legacy PHP pages with query params built from data, ensure values are not attacker-controlled HTML. Check that dynamic:hrefbindings only interpolate integer IDs or known-safe strings. - Open redirects. If a controller builds a redirect URL using user-supplied or database values (e.g., a username), always
urlencode()those values before embedding them in the URL. Verify that the Vue component does not usewindow.location.hrefwith unsanitized query param values. - Mass parameter manipulation. Verify that array parameters from the client (e.g.,
sessionIds[]) are cast to safe types (array_map('intval', ...)) before use. Verify that a non-admin user cannot supply IDs of entities they do not own/manage.
Chamilo LMS 2.0 — an open-source e-learning platform built on Symfony 6.4 (PHP 8.2/8.3) with a Vue 3 frontend. Uses API Platform 3.0 for REST/GraphQL APIs, Doctrine ORM for persistence, and Webpack Encore for asset compilation.
yarn install # Install JS dependencies
yarn dev # Development build (Webpack Encore)
yarn watch # Dev build with file watching
yarn build # Production buildcomposer install # Install PHP dependencies
php bin/console doctrine:migrations:migrate # Run database migrations
php bin/console doctrine:fixtures:load # Load test fixtures
php bin/console cache:clear # Clear Symfony cachecomposer phpcs # Check PHP code style (ECS/Symfony standard)
composer phpcs-fix # Auto-fix PHP code style
composer phpstan # Static analysis (level 5)
composer psalm # Type checkingphp bin/phpunit # Run full test suite
php bin/phpunit tests/CoreBundle/Path/To/Test.php # Run a single test file
php bin/phpunit --filter testMethodName # Run a single test methodTests use PHPUnit 9.6 with DAMA DoctrineTestBundle for transaction isolation. The test environment is configured via .env.test with APP_ENV=test.
npx eslint assets/ # Lint JS/Vue files
npx prettier --check . # Check formattingIt is possible to test the application through the web, as admin, by calling locally: http://my.chamilo.net with credentials admin/admin.
Behat feature files live in tests/behat/features/. They are organised by domain (e.g. hr/, admin/). The shared step definitions are in tests/behat/features/bootstrap/FeatureContext.php.
Run a specific feature file:
vendor/bin/behat tests/behat/features/createUsers.featureRun all features:
vendor/bin/behat --suite=default tests/behat/features/When to write Behat tests — mandatory rule: Every new feature and every new interface added to an existing feature must be accompanied by a Behat feature file (or additions to an existing one) that covers all user interactions: create, read, edit, and delete at minimum.
- Place feature files in
tests/behat/features/<domain>/mirroring the view directory structure. - Form inputs in Vue dialogs/forms must have a
nameattribute so Behat can target them withI fill in "name" with "value"andI select "option" from "name". - If an entity or page is accessible to more than one role, run all scenarios once per role that has access. Do not test only as admin if HR users or other roles can also interact with the feature. For access-restricted pages (e.g. admin-only), add a scenario verifying that non-privileged roles are denied access (redirected or do not see the management UI).
- Login steps available:
I am a platform administrator,I am an HR user(hr/HrHrHr11+),I am an HR manager(ptook),I am a student,I am a teacher. Add a new named step toFeatureContext.phpwhenever a new test user with non-standard credentials is needed. - Each feature file must be self-contained: it creates all data it needs and deletes it at the end, leaving the database in the same state it found it.
Three Symfony bundles:
- CoreBundle — Main application logic (~215 entities, controllers, services, API resources, migrations)
- CourseBundle — Course-specific entities and logic (entities prefixed with
C, e.g.,CDocument,CCalendarEvent) - LtiBundle — LTI (Learning Tools Interoperability) integration
Standard Symfony patterns within each bundle: Entity/, Repository/, Controller/, Service/, EventListener/, EventSubscriber/, Form/, Command/.
API Platform resources are defined via PHP attributes on entities. Custom API resources live in src/CoreBundle/ApiResource/. GraphQL queries/mutations are in src/CoreBundle/GraphQL/.
Database migrations are in src/CoreBundle/Migrations/Schema/V200/.
public/main/ contains legacy PHP code (pre-Symfony). public/legacy.php bootstraps legacy class autoloading via composer.json classmap entries. Legacy code is gradually being migrated to Symfony.
Vue 3 SPA in assets/vue/:
- Entry point:
main.js— bootstraps Vue app with Pinia + Vuex stores, Vue Router, i18n, PrimeVue, and registers CRUD store modules per entity - Router:
router/index.js— imports route modules per feature domain (documents, messages, courses, etc.) - Views:
views/— page components organized by feature (e.g.,views/documents/,views/message/) - Components:
components/— reusable components,components/basecomponents/for shared UI primitives - Composables:
composables/— Vue 3 composition API hooks - Services:
services/— API client wrappers using Axios;services/baseService.jsprovides generic CRUD methods returning Hydra-format collections - Store:
store/— Pinia stores (securityStore.js,cidReq.js, etc.) plus Vuexmodules/crud.jsfor entity CRUD operations - GraphQL:
graphql/queries/— GraphQL query definitions used via Apollo Client
The API returns JSON-LD/Hydra format (hydra:member, hydra:totalItems, etc.).
CSS uses Tailwind CSS 3.4 with SCSS. Legacy pages also use Bootstrap 5 and jQuery.
- Entity IRIs: The frontend references entities by their API Platform IRI (e.g.,
/api/users/1) rather than raw IDs - CidReq: Course/session context is tracked via
cidReqstore — holds current course ID, session ID, and group ID passed as query parameters - Resource system: Content entities extend
AbstractResourceand use Chamilo's resource node/link system for access control and file management - Services pattern (frontend): Each entity type has a service file that wraps
baseServiceto interact with a specific API endpoint
- Symfony config:
config/packages/(doctrine, security, api_platform, messenger, etc.) - Environment variables:
.env(copy from.env.dist),.env.testfor tests - Webpack:
webpack.config.js— entry points forvue(main SPA),legacy_*(legacy pages), style entries - ESLint:
eslint.config.mjs(flat config, Vue 3 + Prettier) - Prettier:
.prettierrc.json(120 width, no semicolons, single attribute per line) - PHP code style:
ecs.php(Symfony standard + PSR-12) - PHPStan:
phpstan.neon(level 5)
declare(strict_types=1);at the top of every file.- Yoda conditions:
'all' === $listType, not$listType === 'all'. - String concatenation with no spaces around
.:'%'.$keyword.'%'. - Short array syntax
[], trailing commas in multiline constructs. - No useless
else— use early returns. No uselessreturnat end of void methods. - No empty or superfluous phpdoc (tags that duplicate type hints).
- Declare
voidreturn type on methods that return nothing. - Import classes via
usestatements — no inline\Fully\Qualified\Namein code. - Ordered class elements: constants → properties → constructor → public → protected → private.
- Modern type casting:
(int)notintval(). setParameter()in QueryBuilder infers the binding type automatically for integers, string/int arrays,DateTimeinstances and managed entities, so passing those directly is functionally correct. However, inferring the type of an entity object forces Doctrine to resolve its metadata to extract the identifier — Psalm'sQueryBuilderSetParameterflags this as a performance cost. So: prefer passing the scalar identifier of an entity, e.g.->setParameter('course', (int) $course->getId())(Core entities) or(int) $entity->getIid()(CourseBundle entities). Once the value is a cast scalar, omit the 3rd-arg type — inference is trivial andTypes::INTEGERis redundant. Reserve the explicit 3rd arg (Types::*/ParameterType::*) for when you need a binding type different from the inferred one (e.g. forcingTypes::STRINGon a numeric string) or for genuinely non-scalar values. Note: some existing code still passes..., Types::INTEGERafter a cast — harmless legacy redundancy, not a pattern to copy.- All methods must have return types; all parameters must have type hints.
- No semicolons, 120 char line width, Prettier formatting
- Vue components use Composition API with
<script setup>pattern preferred - Frontend route files mirror the view directory structure (one route file per feature domain)
- CourseBundle entities are prefixed with
C(e.g.,CDocument,CBlog,CForumPost) - API responses use JSON-LD/Hydra vocabulary
The Vue SPA is not a catch-all. Every URL the browser can navigate to directly must be registered in two places:
src/CoreBundle/Controller/IndexController.php— as a#[Route(...)]attribute onindex(), so Symfony serves the HTML shell that boots the SPA.assets/vue/router/admin.js(or the relevant domain router file) — so the Vue Router handles it client-side.
Data/API endpoints must use a different URL from the SPA route they serve. Convention: append -data (e.g., SPA at /skill/ranking, data endpoint at /skill/ranking-data). Failing to split these causes Symfony to return raw JSON when the user navigates directly to the page.
assets/locales/*.json files are generated by the command:
php bin/console chamilo:update_vue_translationsThe pipeline is: translations/messages.pot → translations/messages.en_US.po (and other .po files) → assets/locales/*.json.
assets/locales/en_US.jsonis the master key list — every key that should exist in all languages must appear here.- If a key is missing from
messages.pot/messages.en_US.po, the raw English key is returned for all languages (no translation possible). - Vue
{0}/{1}placeholders are stored as%s/%din.pofiles; the command converts them automatically. t()fromuseI18n()and$t()in templates behave identically whenlegacy: falseis set (which it is). If a key returns untranslated, it is missing from the locale files — not a code bug.- Placeholders in
assets/locales/en_US.jsonvalues must use{0},{1}, etc. (vue-i18n positional syntax) — never%sor%d. In the corresponding.po/.potentries, use%s/%d; the generation command converts them to{0}/{1}automatically. Call site:t("key", [value])with an array, nott("key", { count: value })(named params).
- DQL uses entity property names, not column names. Always read the entity file before writing a query. Example trap:
SkillRelGradebookhas property$gradeBookCategory(capital B), notgradebookCategory. - Do not order by SELECT aliases in DQL (
orderBy('myAlias', 'DESC')is unreliable). Use the full expression:orderBy('COUNT(sru.id)', 'DESC'). USER_SOFT_DELETED = -2(defined inpublic/main/inc/lib/api.lib.php). The value-1isINACTIVE_AUTOMATIC— a different state. The User entity constants areSOFT_DELETED = -2,INACTIVE_AUTOMATIC = -1,INACTIVE = 0,ACTIVE = 1.- DQL has no
UNION. Replace with two separate queries merged in PHP viaarray_merge.
Adding a variable to a *SettingsSchema.php file (e.g. SecuritySettingsSchema.php) only defines its default value and form type — that alone is not enough for the setting to reach existing installations:
- Fresh installs pick it up automatically:
public/main/install/index.phpcallsSettingsManager::installSchemas(), which reads every registered*SettingsSchemaclass and inserts a row per setting. - Existing/upgraded installations never run this —
doctrine:migrations:migrateis the only thing that runs on upgrade, and it does not load fixtures or re-runinstallSchemas(). Without a migration, the setting reads back its schema default viaSettingsManager::getSetting()(so code doesn't crash), but there is no row insettings/settings_current, and it is invisible in Administration > Settings until one is added.
So a new setting needs both:
- An entry under the right category key in
SettingsCurrentFixtures::getNewConfigurationSettings()(src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php) —['name' => ..., 'title' => ..., 'comment' => ...]. - A fixtures-upsert migration that re-runs the same logic as
Version20250926174000.php: it reads every setting fromSettingsCurrentFixturesplus the schema-declared defaults (SettingsManager::getSchemas()),UPDATEs category/title/comment for settings that already have a row (never touchingselected_value— an admin's configured value survives being re-synced), andINSERTs a row (using the schema default) for ones that don't yet exist. Itsdown()is intentionally a no-op — this is a one-way sync, not meant to be reversed.
Only one such migration is needed per version (i.e. per Schema/V210/ directory, or whichever is current) — check that directory first:
- If one already exists, don't create a second one. Just add the fixture entry from step 1; whoever needs the DB updated re-executes the existing migration manually (
doctrine:migrations:execute '<FQCN>' --up), since Doctrine won't automatically re-run a migration already marked as executed. Developers/ops are expected to know this and re-run it after pulling changes that add settings. - Only create a new migration (copy
Version20250926174000.php's body verbatim into a freshly datedVersion<timestamp>class, same namespace) if the current version's directory doesn't have a fixtures-upsert migration yet.
AbstractMigrationChamilo::addSettingCurrent() (src/CoreBundle/Migrations/AbstractMigrationChamilo.php) exists as a single-setting-insert helper but has zero callers repo-wide — the fixtures-upsert migration above is the actual convention to copy, not that helper.
When replacing a legacy PHP page, search these locations for old links:
src/CoreBundle/Controller/Admin/IndexBlocksController.php— admin panel block linkspublic/main/admin/index.php— legacy admin panelpublic/main/template/default/— legacy Twig templatesassets/vue/components/— Vue components linking to legacy pages (e.g.,social/MySkillsCard.vue)public/main/inc/ajax/model.ajax.php— jqGrid AJAX allowlists; remove the action from both the allowlist arrays and thecaseblocks
Authoritative reference: the use-base-components skill (.claude/skills/use-base-components/SKILL.md).
It auto-invokes whenever you create or edit a Vue interface and documents every Base*
component's API, the native-element → Base* mapping, the label-translation rule, and which
components are globally registered (so you don't import them). When building UI, follow it.
Quick essentials that apply everywhere:
- Tables →
BaseTable(wraps PrimeVueDataTable;Columnis global, no import needed). - Buttons →
<BaseButton>instead of plain<button>. Props:type,icon(MDI name withoutmdi-),only-icon+size="small"for icon-only row actions. - Every non-global component used in the template must be imported in
<script setup>— a missing import does NOT fail the build, only warns at runtime.
CRUD color convention (type prop):
- Create/add/save/import → green →
type="success" - Read/view/export/list → blue →
type="primary" - Update/edit/configure/move → orange →
type="secondary" - Delete/disable/remove → red →
type="danger" - Cancel/dismiss → gray →
type="plain" - Buttons are for actions only — never style a non-action link as a button.
Table row action convention:
- Edit:
type="secondary-text",icon="pencil",only-icon,size="small" - Delete:
type="danger-text",icon="delete",only-icon,size="small" - Never put
ch-tool-iconon icons inside a button — the icon inherits the button's text colour automatically.
8-point grid (multiples of 8px). Fine adjustments of 4/6/12px are acceptable.
Always use <BaseIcon> instead of a plain <icon> element unless there is a clear reason not to. For standalone decorative icons outside of buttons, prefer <BaseIcon icon="{name}" /> over the raw <span class="mdi mdi-{name} ch-tool-icon" /> pattern. The ch-tool-icon class applies blue colouring — use it only outside of buttons. Never add it to icons inside <button> or BaseButton — the icon inherits the button's own text colour.
Common icon names:
- Edit:
mdi-pencil, Delete:mdi-delete, Add:mdi-plus-box, Search:mdi-magnify - Copy:
mdi-text-box-plus, Configure:mdi-hammer-wrench, Info:mdi-information - Subscribe users:
mdi-account-multiple-plus, Add courses:mdi-book-open-page-variant - Export CSV:
mdi-file-delimited-outline, Export PDF:mdi-file-pdf-box - Visible/invisible:
mdi-eye/mdi-eye-off - Active/inactive:
mdi-toggle-switch/mdi-toggle-switch-off
- Planned/info:
bg-blue-100 text-blue-700 - Active/success:
bg-green-100 text-green-700 - Finished/neutral:
bg-gray-100 text-gray-700 - Error/cancelled:
bg-red-100 text-red-700
Always use the useConfirmation composable (assets/vue/composables/useConfirmation.js) instead of the native confirm() dialog. It wraps PrimeVue's confirm service and is already i18n-aware.
import { useConfirmation } from "../../composables/useConfirmation"
const { requireConfirmation } = useConfirmation()
function confirmDelete(item) {
requireConfirmation({
message: t("Are you sure you want to delete this item?"),
accept: () => performDelete(item),
})
}The title defaults to t("Confirmation") and message defaults to t("Please confirm your choice") if omitted. The reject callback is optional.
Every new Vue page must have a breadcrumb. The breadcrumb is built automatically by assets/vue/components/Breadcrumb.vue from the route tree — no code is needed inside the view itself. What is required:
- Set
meta: { showBreadcrumb: true, breadcrumb: "Page title" }on the route entry in the relevant router file (e.g.assets/vue/router/admin.js). - If the page lives under a new top-level path (not
/admin/*), add the path prefix to thewhitelistarray inbuildManualBreadcrumbIfNeededinsideBreadcrumb.vue, and add a correspondingifblock that pushes an "Administration" crumb (linked toAdminIndex) followed by the page label. See the/admin/*case as a reference.
Admin pages should always show: Administration (linked) / Page title (plain text).
Use Base* form components, not native <input>/<select>/<textarea> — see the
use-base-components skill for the mapping and each component's API. Group fields with
flex gap-4 items-end. Only fall back to a native element when a third-party slot requires one
(e.g. multi-value checkbox arrays bound to a v-model array).
When creating or editing Vue interfaces, always prefer client-side routing over plain <a href> to avoid full-page reloads:
<BaseButton :route="{ name: 'RouteName', params: { id } }">for button navigation within the SPA.<router-link :to="{ name: 'RouteName', params: { id } }">for non-button link text.router.push({ name: 'RouteName' })in script logic (after async operations, etc.).- Only use
:to-urlonBaseButtonor plain<a href>when the target is outside the SPA (e.g., a Twig controller, a file download, a legacy PHP page that hasn't been migrated yet).
Whenever a controller or script no longer needs to read or write session data, call session_write_close() immediately to release the session file lock. Holding the lock blocks concurrent requests from the same browser (e.g. parallel API calls from Promise.all in Vue).
// As soon as session reads/writes are done:
session_write_close();This applies to all legacy PHP scripts and any Symfony controller that accesses the session early and then performs slow work (DB queries, file I/O, external HTTP calls).
See .claude/commands/legacy-to-vue.md for the step-by-step checklist, invokable as /legacy-to-vue in Claude Code.
Course/session/group membership is exposed to API Platform security: expressions as six contextual roles, all defined in ResourceNodeVoter:
| Role | Granted when the user is… |
|---|---|
ROLE_CURRENT_COURSE_STUDENT |
subscribed to the current course (course_rel_user) |
ROLE_CURRENT_COURSE_TEACHER |
a teacher of the current course |
ROLE_CURRENT_COURSE_SESSION_STUDENT |
a student of the current course inside the current session |
ROLE_CURRENT_COURSE_SESSION_TEACHER |
a general coach of the session or a course coach in the current course |
ROLE_CURRENT_COURSE_GROUP_STUDENT |
a member of the current group |
ROLE_CURRENT_COURSE_GROUP_TEACHER |
a tutor of the current group, or the teacher of its parent course |
These roles only exist for the current request. They are computed and published in two places by CourseContextRoleListener (src/CoreBundle/EventListener/CourseContextRoleListener.php, priority 5) after CidReqListener (priority 6) resolves cid/sid/gid:
User::$temporaryRoles— visible toSecurity::getUser()->getRoles()andResourceNodeVoter::hasContextRole().- The security token's
getRoleNames()— visible tois_granted()and theRoleHierarchyVoter.
The relationship logic lives in CourseAccessResolver (src/CoreBundle/Security/CourseAccessResolver.php), a pure service consumed by the listener. Voters must never call $user->addRole(ROLE_CURRENT_COURSE_*) — that pattern was removed in #8486 because Voter side-effects break Symfony's contract (non-deterministic order, short-circuit evaluation, etc.).
The contextual-role model governs API operations that act inside a course tool — the request carries cid/sid/gid and the resource is course-scoped content. It is not a blanket rule for every entity:
CourseBundle#[ApiResource]entities — applies to all exceptCCalendarEvent, which is authorized by its dedicatedCCalendarEventVoter(creator / collective-subscription ownership, not course context).CoreBundle#[ApiResource]entities — review case by case. Many are not course-scoped (users, messages, social posts, user relations, sessions) and are governed by their own dedicated voters; only apply the contextual-role patterns to entities that genuinely operate inside a course tool.- Any resource with a dedicated voter (e.g.
CCalendarEventVoter,UsergroupVoter,SessionVoter) keeps that voter as the authority — do not bolt contextual-role expressions on top of it.
config/packages/security.yaml declares one-way implications within each axis:
COURSE_TEACHER ─implies─> COURSE_STUDENT
SESSION_TEACHER ─implies─> SESSION_STUDENT
GROUP_TEACHER ─implies─> GROUP_STUDENT
There is no implication between axes. A base-course teacher does NOT automatically get SESSION_STUDENT. Admins get all three _TEACHER variants via ROLE_ADMIN, which transitively grants all six.
Use the matrix below when writing or migrating an #[ApiResource] operation that receives cid (and optionally sid/gid) as query parameters (not body — CidReqListener does not parse bodies):
// Get / Put / Patch / Delete on a single resource that extends AbstractResource:
// prefer object-level checks — they are more granular than role-level.
new Get(security: "is_granted('VIEW', object.resourceNode)"),
new Put(security: "is_granted('EDIT', object.resourceNode)"),
new Delete(security: "is_granted('DELETE', object.resourceNode)"),
// GetCollection / download / search — any member of the course or session:
security: "is_granted('ROLE_CURRENT_COURSE_STUDENT')
or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"
// Post / batch write — only teachers of the current course or session:
security: "is_granted('ROLE_CURRENT_COURSE_TEACHER')
or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')"CDocument (src/CourseBundle/Entity/CDocument.php) is the canonical reference — see lines 50-300 for examples of every pattern above.
- Body-only
ciddoesn't work.CidReqListenerreadsRequest::get('cid'), which sees query params and route attributes but not request bodies. Endpoints that passcidonly inside the JSON body need either an extracidquery param (preferred) or post-security validation in aStateProcessor. Post(create) can't use object-level checks. On create theresourceNodedoes not exist yet, sois_granted('CREATE', object)/object.resourceNodefails closed. Gate creation with the contextual teacher roles through an operation-ownsecurity:(notsecurityPostDenormalize), which also avoids inheriting a resource-levelsecurity:that may omitSESSION_TEACHERand would otherwise block session teachers.CToolIntrois the reference.- Output format negotiation runs before security. Endpoints with binary
outputFormats(zip,bin) reject the request with 406 if theAcceptheader isapplication/ld+json. Regression tests must sendAccept: application/zip(or the matching MIME type) to reach the security gate. - The skill
/migrate-contextual-roles <Entity>automates this migration for a single entity, including Vue-caller compatibility checks and lint/test runs.
For CoreBundle resources that are not course-scoped but are owned by one user (e.g. PushSubscription, personal tokens, per-user settings rows), do not try to express ownership with security: "... and object.getUser() == user". That expression returns 403 (leaks the row's existence), silently blocks admins unless you also or is_granted('ROLE_ADMIN'), and duplicates the rule across operations. Each concern has its own idiomatic tool — an API Platform Voter acts on a single item only, so split the work three ways:
| Concern | Operations | Tool | Why |
|---|---|---|---|
| Per-object authorization | Get / Put / Patch / Delete |
Voter (is_granted('VIEW'|'EDIT'|'DELETE', object)) |
Object exists; voter is reusable from non-API code too |
| Collection scoping | GetCollection |
QueryCollectionExtensionInterface in src/CoreBundle/DataProvider/Extension/ |
A voter can't filter a collection; an extension adds a WHERE so foreign rows are never selected |
| Create / mass-assignment | Post |
Processor forcing $data->setUser($currentUser) |
On create no object exists yet, so no voter can run — forcing the owner is the only mass-assignment defense |
Voter — standard Symfony voter on the entity, e.g. returns $subscription->getUser() === $user || $security->isGranted('ROLE_ADMIN'). Reference dedicated voters already in the repo: CCalendarEventVoter, UsergroupVoter, SessionVoter. Wire it on the item operations with security: "is_granted('DELETE', object)".
Collection extension — a QueryCollectionExtensionInterface in src/CoreBundle/DataProvider/Extension/ that adds the ownership WHERE on the root alias, with an admin bypass. API Platform runs every registered collection extension (including the built-in Filter / Order / Pagination) automatically, so you only add your WHERE — no need to re-apply those extensions by hand. Canonical reference: SessionRelUserExtension (src/CoreBundle/DataProvider/Extension/SessionRelUserExtension.php):
final class XExtension implements QueryCollectionExtensionInterface
{
public function __construct(private readonly Security $security) {}
public function applyToCollection(QueryBuilder $qb, QueryNameGeneratorInterface $g, string $resourceClass, ?Operation $op = null, array $ctx = []): void
{
if (X::class !== $resourceClass || $this->security->isGranted('ROLE_ADMIN')) {
return;
}
$user = $this->security->getUser() ?? throw new AccessDeniedException();
$alias = $qb->getRootAliases()[0];
$qb->andWhere("$alias.user = :current_user")->setParameter('current_user', $user->getId());
}
}Processor — forces ownership on create (and can also re-validate on delete). Branch on $operation instanceof DeleteOperationInterface; inject the inner services with #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] / ...remove_processor.
When to fall back to an all-in-one State Provider instead of a Voter+Extension: only when the collection can't be a plain WHERE (custom/aggregated data source — see StickyCourseStateProvider), or when you must return 404 instead of the voter's 403 for foreign items (no existence disclosure). The merged PushSubscription fix used a single PushSubscriptionStateProvider + PushSubscriptionStateProcessor for the latter reason; for the typical case prefer the Voter + Extension + Processor split above.
Cross-cutting rules learned here:
- Current user:
UserHelper::getCurrent(): ?User(injectUserHelper), never$security->getUser()— except inside aQueryCollectionExtension, where$this->security->getUser()is the established idiom (seeSessionRelUserExtension). - Role checks:
$security->isGranted('ROLE_ADMIN')(respects the hierarchy), never$user->isAdmin()/hasRole(). - Serialization: owner relation → read-only group (mass-assignment defense); secrets (tokens, keys) → write-only group so they are never echoed back.
- No
services.yamlwiring needed: classes implementingVoterInterface/QueryCollectionExtensionInterface/ProcessorInterfaceare auto-tagged viaautoconfigure; inner API Platform services are pulled in with#[Autowire(service: ...)]. - Regression test gotcha: the test DB host in
.env.testmay not be reachable from the PHP container — overrideDATABASE_HOST/DATABASE_PASSWORDenv vars on thephp bin/phpunitcall. To test admin access over a JWT Bearer token, create the admin withcreateUser('name', '', '', 'ROLE_ADMIN')(it registers theUserAuthSource::PLATFORMrow thatUserAuthSourceListenerrequires); the fixtureadmin/adminaccount may lack it in a fresh test DB.