
Comment démarrer
Ce guide s'adresse aux développeurs qui vont étendre ou personnaliser Jul6art SaaS Admin pour leur organisation ou leurs clients. Il complète la présentation commerciale en montrant comment utiliser concrètement les mécaniques du socle.
Sommaire
- Pré-requis & bootstrap
- Le modèle de permissions (module → rôle → user)
- Ajouter un module métier
- Le système d'événements (hooks)
- Paramètres par organisation (Settings)
- Temps réel Mercure
- Notifications
- Audit & purge
- Assistant LLM
- CMS — thèmes, shortcodes, menus
- API JSON-LD & autocomplete
- Tests
- Référence rapide des modules
1. Pré-requis & bootstrap
La stack s'exécute en Docker. Le dépôt contient un docker-compose.yml fonctionnel.
# Démarrer l'environnement
docker compose up -d
# Installer les dépendances PHP
docker compose exec php composer install
# Créer la base + appliquer les migrations + charger les fixtures
docker compose exec php bin/console doctrine:database:create
docker compose exec php bin/console doctrine:migrations:migrate --no-interaction
docker compose exec php bin/console doctrine:fixtures:load --no-interaction
# Build des assets
docker compose exec node yarn install
docker compose exec node yarn build
Applications accessibles :
- Backoffice super-admin :
http://jul6art.localhost:8080/admin - Backoffice organisation :
http://{org_slug}.jul6art.localhost:8080/organization - Site public d'une org :
http://{org_slug}.jul6art.localhost:8080/ - API Platform :
http://jul6art.localhost:8080/api - Mailpit (mails de test) :
http://localhost:8025 - Mercure :
http://localhost:3000
Convention forte : toutes les commandes Symfony passent par Docker. Jamais php bin/console en local sans préfixe.
2. Le modèle de permissions
Trois couches évaluées dans cet ordre pour chaque route applicative :
2.1 Feature flag (kill switch module)
Un module est activé par organisation via OrganizationFeature. Une route protégée porte un attribut #[RequiresFeature] au niveau classe :
#[Route('/organization/cms')]
#[RequiresFeature('cms.manage')]
#[IsGranted('ROLE_USER')]
class CmsPageController extends AbstractController
{
// ...
}
FeatureAccessSubscriber redirige automatiquement vers /access-denied si l'org n'a pas la feature. Côté Twig :
{% if is_feature_enabled('cms.manage') %}
<a href="{{ path('app_organization_cms_page_index') }}">CMS</a>
{% endif %}
Activation via FeatureService::assign($org, 'cms.manage', $actor) — cascade les permissions et les settings par défaut automatiquement.
2.2 Permission code (action fine)
Chaque méthode mutante porte #[IsGranted(PermissionCodes::*)] :
#[Route('/{id}/publish', methods: ['POST'])]
#[IsGranted(PermissionCodes::CMS_PAGE_PUBLISH)]
public function publish(Page $page): Response
{
$this->denyAccessUnlessGranted(PageVoter::PUBLISH, $page);
$this->pageService->publish($page, $this->getUser()->getId());
return $this->redirectWithSuccess('...', 'cms.page.flash.published');
}
Règle dure : chaque endpoint bulk-* porte aussi son #[IsGranted] — la boucle voter par entité est la 2ᵉ couche, pas la 1ʳᵉ.
2.3 Voter (ownership + scope)
Un Voter vérifie que l'utilisateur peut accéder à cette entité (appartenance à son org, règles métier). Tous les voters étendent AbstractVoter et réutilisent PermissionDecisionService :
final class PageVoter extends AbstractVoter
{
public const VIEW = 'cms_page.view';
public const PUBLISH = 'cms_page.publish';
public function __construct(
private readonly PermissionDecisionService $permissionDecisionService,
) {}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User or !$subject instanceof Page) {
return false;
}
// Cross-org refusé
if ($subject->getOrganization()->getId() !== $user->getOrganization()?->getId()) {
return false;
}
return match ($attribute) {
self::VIEW => $this->permissionDecisionService->isGranted($user, PermissionCodes::CMS_PAGE_READ),
self::PUBLISH => $this->permissionDecisionService->isGranted($user, PermissionCodes::CMS_PAGE_PUBLISH),
default => false,
};
}
}
2.4 Délégation user-override
Un admin d'org peut accorder ou retirer une permission à un utilisateur spécifique, au-delà de son rôle, via l'UI /organization/users/{id} ou programmatiquement :
$permissionDelegationService->grantPermissionToUser(
$actor, // qui fait l'override (doit lui-même avoir la permission)
$targetUser, // bénéficiaire
PermissionCodes::CMS_PAGE_PUBLISH,
);
Le service refuse les tentatives cross-org et la délégation d'une permission que l'acteur n'a pas lui-même.
2.5 Rôles par défaut par organisation
À la création d'une org, RolePermissionSeederService::seedDefaultsFor() provisionne les matrices :
ROLE_ORGANIZATION_ADMINreçoit les permissions complètes de chaque feature enabled.ROLE_ORGANIZATION_MANAGERreçoit un sous-ensemble (read + create/update, pas delete/publish).ROLE_USERreçoitcms:page:read,cms:blog:read,cms:media:read(selon les features).
Les matrices sont dans App\Core\Security\DefaultRolePermissions.
3. Ajouter un module métier
3.1 Squelette
Créez un dossier src/Modules/MonModule/ avec :
src/Modules/MonModule/
├── Controller/
│ ├── Admin/ # Super-admin cross-org
│ └── Organization/ # Scope tenant
├── Entity/
├── Form/
├── Repository/
├── Security/ # Voters
├── Service/ # Services métier + DataTableConfigProvider
├── Event/ # Events métier
├── EntityListener/ # Doctrine CRUD events
└── Setting/ # Defaults provider (si applicable)
3.2 Déclarer une feature
Ajoutez la feature à App\Modules\Feature\Service\PlanFeatureCatalog et à App\Core\Security\DefaultRolePermissions::getFeaturePermissions(). Créez une fixture dans FeatureFixtures si vous voulez qu'elle apparaisse automatiquement.
3.3 Déclarer les permissions
Ajoutez les constantes dans App\Core\Security\PermissionCodes (convention MODULE_RESOURCE_ACTION → cms:page:publish).
3.4 Créer l'entité
Pattern obligatoire :
#[ORM\Entity]
#[ORM\Table(name: 'mon_module_resource')]
#[ORM\UniqueConstraint(columns: ['organization_id', 'slug'])]
#[ORM\HasLifecycleCallbacks]
#[Auditable(ignoredFields: ['updatedAt', 'viewCount'])]
#[Notifiable(event: 'created', type: 'mon_module.resource.created', recipients: 'org_admins')]
#[ApiResource(
operations: [/* ... */],
normalizationContext: ['groups' => ['mon_module:read']],
cacheHeaders: ['max_age' => 30, 'shared_max_age' => 120, 'public' => false],
)]
class Resource
{
use TimestampableTrait;
use SoftDeletableTrait;
#[ORM\ManyToOne(targetEntity: Organization::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Organization $organization;
// ...
}
HasOrganizationTrait + OrganizationFilter (Doctrine SQLFilter globale) garantissent l'isolation multi-tenant automatique.
3.5 Service métier
class ResourceService extends AbstractService
{
public function __construct(
AuditLogger $auditLogger,
LoggerInterface $logger,
EventDispatcherInterface $eventDispatcher,
private readonly EntityManagerInterface $entityManager,
private readonly ResourceRepository $repository,
) {
parent::__construct($auditLogger, $logger, $eventDispatcher);
}
public function create(Organization $org, string $name, ?int $actorId = null): Resource
{
$resource = (new Resource())->setOrganization($org)->setName($name);
$this->eventDispatcher->dispatch(new ResourceEvent($resource, $actorId), ResourceEvent::BEFORE_CREATED);
$this->entityManager->persist($resource);
$this->entityManager->flush();
$this->auditLogger->log('resource.created', $org->getId(), $actorId, 'Resource', $resource->getId());
$this->eventDispatcher->dispatch(new ResourceEvent($resource, $actorId), ResourceEvent::AFTER_CREATED);
return $resource;
}
}
3.6 Voter + controller
Copiez le pattern de PageVoter / CmsPageController. Le controller applique les 3 couches (feature + permission code + voter).
3.7 DataTable
Étendez AbstractDataTableConfigProvider pour définir colonnes, filtres, actions. Côté Twig, une seule ligne <table data-controller="core--datatable"> suffit — le contrôleur Stimulus gère recherche, tri, pagination, filtres Select2 AJAX, confirmations modales.
4. Le système d'événements (hooks)
Deux catégories :
4.1 Events CRUD (automatiques via Doctrine)
Créez un EntityListener dans le module qui étend AbstractEntityListener :
#[AsEntityListener(event: Events::prePersist, entity: Resource::class)]
#[AsEntityListener(event: Events::postPersist, entity: Resource::class)]
// ... updated, deleted
class ResourceEntityListener extends AbstractEntityListener
{
protected function createEvent(object $entity, ?int $actorId): AbstractEntityEvent
{
return new ResourceEvent($entity, $actorId);
}
protected function getBeforeCreatedEventName(): string { return ResourceEvent::BEFORE_CREATED; }
protected function getAfterCreatedEventName(): string { return ResourceEvent::AFTER_CREATED; }
// ...
}
Les events se déclenchent automatiquement, peu importe l'origine (service, fixture, import CSV, API Platform).
4.2 Events métier (explicites)
Dans le service :
$event = new ResourceEvent($resource, $actorId);
$this->eventDispatcher->dispatch($event, ResourceEvent::BEFORE_PUBLISHED);
if ($event->isAborted()) {
throw new \DomainException($event->getAbortReason() ?? 'resource.publish.aborted');
}
$resource->setStatus(Status::Published);
$this->entityManager->flush();
$this->eventDispatcher->dispatch($event, ResourceEvent::AFTER_PUBLISHED);
4.3 Écrire un listener pour personnaliser
Dans un src/Modules/MonClient/EventListener/ :
#[AsEventListener(event: PageEvent::AFTER_PUBLISHED)]
final class SyncPageToExternalCrmListener
{
public function __invoke(PageEvent $event): void
{
$page = $event->getPage();
// push vers un CRM externe, un Data Warehouse, un Slack...
$this->externalClient->publish($page);
}
}
Pas de fork du cœur. Pas de conflit à chaque upgrade.
4.4 #[Notifiable] — notifier sans écrire de code
#[Notifiable(
event: 'published',
type: 'cms.page.published',
titleKey: 'notification.cms.page.published.title',
messageKey: 'notification.cms.page.published.message',
recipients: 'org_admins', // ou 'super_admins', 'org_members', ou une stratégie custom
)]
class Page { /* ... */ }
Dispatchez NotificationService::notify('published', $page) dans votre service métier → le système crée la notif + push Mercure sur le topic du destinataire.
5. Paramètres par organisation (Settings)
5.1 Déclarer les defaults d'un module
// src/Modules/MonModule/Setting/MonModuleSettingsDefaults.php
final class MonModuleSettingsDefaults implements ModuleSettingsDefaultsInterface
{
public static function featureCode(): string { return 'mon_module.manage'; }
public static function moduleKey(): string { return 'mon_module'; }
public static function defaults(): array
{
return [
new SettingDefault('mon_module.quota', '100', SettingScope::Organization),
new SettingDefault('mon_module.allow_export', '1', SettingScope::Organization),
];
}
}
Classe taguée automatiquement (_instanceof dans services.yaml). Activation de la feature → rows Setting créées idempotent. Désactivation → suppression DQL.
5.2 Lire
Service dédié par module (pattern CmsSettingsReader, LlmSettingsReader) :
class MonModuleSettingsReader
{
public function __construct(private readonly SettingResolver $settingResolver) {}
public function getQuota(Organization $org): int
{
$raw = $this->settingResolver->resolve('mon_module.quota', $org, 'mon_module');
return \is_numeric($raw) ? (int) $raw : 100;
}
}
En Twig :
{{ setting('mon_module.quota', 'mon_module') }}
5.3 Cascade
module → organization → global → default de code. La valeur renvoyée est toujours un string — chaque reader cast selon son type.
5.4 Prefetch
Les features et settings de l'org courante sont chargés en 2 queries au début de chaque requête HTTP (pour les users non super-admin) via TenantContextBag. Toute lecture postérieure est en mémoire.
6. Temps réel Mercure
6.1 Marquer une entité comme broadcastable
#[BroadcastableEntity(tenantScope: 'organization')]
class Resource { /* ... */ }
Publie automatiquement sur /organizations/{orgId}/feed à chaque postPersist/postUpdate/postRemove.
6.2 Écouter côté JS
Le mixin Stimulus mercurable fait tout :
import { Controller } from '@hotwired/stimulus';
import { useMercurable } from '../../mixins/mercurable';
export default class extends Controller {
connect() {
useMercurable(this);
this.subscribe('/api/resources/{id}', (event) => this._onUpdate(event));
}
}
Auto-unsubscribe au disconnect(). Un seul EventSource partagé pour tous les topics de la page.
6.3 DataTable auto-refresh
core--datatable s'abonne automatiquement aux topics du resource principal et des IRI référencées, invalide le cache, recharge avec debounce 500ms. Zéro configuration.
7. Notifications
- Pose
#[Notifiable]sur l'entité. - Appelle
$this->notificationService->notify('event_name', $entity)dans le service aprèsflush(). - Le dropdown cloche se met à jour via Mercure sur
/notifications/{userId}.
La linkUrl est pré-calculée selon le rôle du destinataire (admin → /admin/..., org → /organization/...).
8. Audit & purge
8.1 Audit
#[Auditable(ignoredFields: ['updatedAt', 'viewCount'])]
class Resource { /* ... */ }
Les events Doctrine postPersist/postUpdate/postRemove créent automatiquement un AuditLog avec diff des champs modifiés (hors ignoredFields). Les appels manuels AuditLogger::log() dans les services couvrent en plus les actions métier spécifiques (ex. cms.page.published).
8.2 Purge
#[Purgeable(field: 'createdAt', interval: '-3 months')]
class AuditLog { /* ... */ }
#[Purgeable(
field: 'createdAt',
interval: '-1 month',
condition: 'entity.isRead() == true',
)]
class Notification { /* ... */ }
Commande :
docker compose exec php bin/console app:purge --dry-run
docker compose exec php bin/console app:purge
Chaque purge crée une ligne d'audit — cycle auto-nettoyé puisque AuditLog est lui-même Purgeable.
9. Assistant LLM
9.1 Activer sur une org
Super-admin :
- Active la feature
llm.writingsur l'org (/admin/organizations/{id}→ onglet Features). - Les settings
llm.writing.*apparaissent dans/admin/settings/modules/llmet sont auto-provisionnés avec les valeurs par défaut (enabled=0,allowed_actions=[rewrite, correct, translate, summarize, continue, custom],default_tone=neutral,rate_limit=60/h).
Admin org :
- Ouvre
/organization/llm/config→ activeenabled=1. - Choisit les actions autorisées.
9.2 Brancher l'assistant sur un éditeur Trix
Le bouton ✨ est injecté automatiquement sur tout <trix-editor> porteur du Stimulus controller cms--editor. Rien à faire côté dev — l'assistant détecte la langue, le contexte fonctionnel, et propose les actions autorisées.
9.3 Créer un contexte LLM custom
// src/Modules/MonModule/Llm/MonModuleWritingContext.php
final class MonModuleWritingContext extends AbstractContext
{
public function getCode(): string { return 'mon_module.description'; }
public function getLabelKey(): string { return 'mon_module.llm.context.description'; }
public function getSystemPrompt(): string
{
return 'Tu es assistant pour la rédaction de descriptions de resources. ...';
}
}
Taguée llm.writing_context automatiquement. Apparaît dans le menu du bouton ✨.
10. CMS — thèmes, shortcodes, menus
10.1 Hiérarchie de templates
L'ordre de résolution :
templates/public/themes/{theme}/page/{slug}.html.twig→ override par pagetemplates/public/themes/{theme}/org/{org-slug}/page.html.twig→ override par orgtemplates/public/themes/{theme}/page.html.twig→ défaut du thème
Créer un fichier au chemin 1 ou 2 suffit à activer l'override. Aucun config.
10.2 Ajouter un thème
- Copie
templates/public/themes/starter/→templates/public/themes/mon-theme/. - Ajoute la case dans
App\Modules\Cms\Enum\BaseTheme. - L'admin org peut sélectionner le nouveau thème dans
/organization/cms/theme.
10.3 Shortcodes
Ajouter un nouveau shortcode :
// src/Modules/MonModule/Twig/MonModuleShortcodeExtension.php
final class MonModuleShortcodeExtension extends AbstractExtension
{
public function getFilters(): array
{
return [new TwigFilter('mon_module_shortcodes', $this->process(...), ['is_safe' => ['html']])];
}
public function process(string $content, Organization $organization): string
{
return \preg_replace_callback(
'/\[mon-module-widget slug="([^"]+)"\]/',
fn(array $m) => $this->renderWidget($m[1], $organization),
$content,
);
}
}
Puis chainer dans le template :
{{ page.content|cms_shortcodes(organization)|mon_module_shortcodes(organization)|raw }}
10.4 Media & sanitization
MediaService::upload applique MIME whitelist + quota + per-file size limit, tous configurables via Setting. PageService::create/update passe le contenu par symfony/html-sanitizer avec le profile cms.editorial avant persist — XSS impossible même si l'éditeur WYSIWYG fuit.
11. API JSON-LD & autocomplete
Toute entité #[ApiResource] est exposée avec JWT + JSON-LD. Le filter Doctrine multi-tenant est appliqué — impossible pour un user d'une org A de lire les ressources de l'org B via l'API.
11.1 Ajouter un <select> Select2 AJAX
Deux types prêts :
OrganizationFormType→ autocomplete organisationsUserFormType→ autocomplete utilisateurs (affiche fullName, cherche sur email+firstName+lastName)
$builder->add('assignee', UserFormType::class, [
'label' => 'Assigné à',
'required' => false,
]);
Lazy loading : zéro row au render, AJAX à la frappe. L'entité bindée apparaît comme <option selected> automatiquement.
11.2 Créer un autocomplete sur une autre entité
Copie le pattern de LazyOrganizationChoiceLoader + OrganizationFormType. Le JWT est déjà exposé globalement par JwtTokenExtension (emis dans le <head> de chaque layout authentifié).
12. Tests
12.1 Lancer
docker compose exec -e APP_ENV=test \
-e DATABASE_URL="postgresql://backoffice:backoffice@db:5432/backoffice_test?serverVersion=16&charset=utf8" \
-e REGISTRATION_ENABLED=true \
php php -d memory_limit=1G vendor/bin/phpunit
12.2 Conventions
- TDD requis sur toute feature non-triviale : test qui échoue d'abord, code qui fait passer, refactor.
- Tests unitaires : services, voters, resolvers, subscribers.
- Tests fonctionnels : parcours HTTP complet avec
WebTestCase. - API : utiliser
ApiTestCase::API_HEADERS(Content-Type: application/ld+json). Format réponse :member/totalItems(pashydra:*).
12.3 Suite de qualité
# PHPStan niveau 8
docker compose exec php vendor/bin/phpstan analyze --no-progress
# Lint Twig + YAML
docker compose exec php bin/console lint:twig templates
docker compose exec php bin/console lint:yaml translations config
# Audit sécu dépendances
docker compose exec php composer audit
13. Référence rapide des modules
| Module | Doc plan | Feature code |
|---|---|---|
| CMS | docs/modules/cms.md |
cms.manage |
| CRM | docs/modules/crm.md |
crm.manage |
| ERP | docs/modules/erp.md |
erp.invoicing, erp.products, erp.inventory, … |
| SIRH | docs/modules/sirh.md |
sirh.core, sirh.leave, sirh.payroll, … |
| Agenda | docs/modules/agenda.md |
agenda.manage |
| Emailing | docs/modules/emailing.md |
emailing.manage |
| Reporting | docs/modules/reporting.md |
reporting.manage |
| LLM Writing | docs/modules/llm.md |
llm.writing |
| Chat | docs/modules/chat.md |
chat.manage |
| Document (GED) | docs/modules/document.md |
document.manage |
Docs transverses
docs/modules/settings.md— système de paramètres et auto-provisioning.docs/modules/mercure.md— architecture temps réel.docs/modules/hooks.md— événements CRUD / métier.docs/notification.md— notifications temps réel + attributs#[Notifiable].docs/purge_and_audit.md— attributs#[Auditable]+#[Purgeable].docs/acl-onboarding.md— guide ACL complet (permissions, delegation, expressions).docs/adr/— décisions d'architecture (auth, multi-tenant, settings, etc.).
Règles projet (à lire absolument)
docs/claude/claude_core.md— conventions fondamentales (nommage, traductions, PHPStan, architecture).docs/claude/claude_project.md— règles spécifiques à cette plateforme (3-couches sécurité, workflow, Docker-first).
Contact tech
Pour toute question architecturale, consulter les ADR (docs/adr/) en premier. Pour un support custom, passez par le contact technique désigné dans votre contrat.
Bon dev 🚀