` — generates a query-only projection. * * Read-models are server-side joined / aggregated views QML reads * without going through a writable `#[BridgeResource]`. Three files: * * - src/ReadModel/ReadModel.php — query service stub * - src/Controller/Controller.php — `GET /api/` * - {qml_path}/List.qml — `ReactiveListModel` bound to the route, * no Mercure topic (read-models aren't reactive — invalidation is * driven by domain events; see `make:bridge:event`). */ final class BridgeReadModelMaker extends AbstractMaker { public function __construct( private readonly string $qmlPath = '../qml/', ) { } public static function getCommandName(): string { return 'make:bridge:read-model'; } public static function getCommandDescription(): string { return 'Generate a read-only projection (query service + GET controller + QML stub).'; } public function configureCommand(Command $command, InputConfiguration $inputConfig): void { $command ->addArgument( 'name', InputArgument::OPTIONAL, 'CamelCase projection name (e.g. TodoSummary, RecentActivity).', ) ->setHelp( "Creates three files:\n\n" ." • src/ReadModel/ReadModel.php — query service (you fill in the body)\n" ." • src/Controller/Controller.php — GET handler at /api/\n" ." • {qml_path}/List.qml — ReactiveListModel bound to the route\n\n" ."Read-models are not auto-reactive. To refresh from server-side changes,\n" ."generate a paired domain event with make:bridge:event.\n" ); } public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void { NameInput::askOrFail( $input, $io, 'name', 'Projection name (CamelCase, e.g. TodoSummary)?', 'Projection name cannot be empty.', ); } public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void { $rawName = (string) $input->getArgument('name'); $singular = ucfirst(Str::asCamelCase($rawName)); $pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular); $resource = Naming::camelTo($singular, '_'); $route = '/api/'.Naming::camelTo($pluralCamel, '-'); $readModelFqcn = $generator->createClassNameDetails( $singular, 'ReadModel\\', 'ReadModel', ); $controllerFqcn = $generator->createClassNameDetails( $singular, 'Controller\\', 'Controller', ); $vars = [ 'singular' => $singular, 'resource' => $resource, 'route' => $route, 'read_model_short' => $readModelFqcn->getShortName(), 'read_model_fqcn' => $readModelFqcn->getFullName(), 'controller_short' => $controllerFqcn->getShortName(), ]; $generator->generateFile( 'src/ReadModel/'.$readModelFqcn->getShortName().'.php', __DIR__.'/templates/ReadModel.tpl.php', $vars, ); $generator->generateFile( 'src/Controller/'.$controllerFqcn->getShortName().'.php', __DIR__.'/templates/ReadModelController.tpl.php', $vars, ); $qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'List.qml'; $generator->generateFile( $qmlTarget, __DIR__.'/templates/ReadModelQml.tpl.php', $vars, ); $generator->writeChanges(); $this->writeSuccessMessage($io); $io->text([ 'Next:', " • Implement {$readModelFqcn->getShortName()}::query().", " • Use {$singular}List.qml in your QML.", ' • Optionally pair with make:bridge:event for invalidation.', ]); } public function configureDependencies(DependencyBuilder $dependencies): void { $dependencies->addClassDependency(EntityManagerInterface::class, 'doctrine/orm'); $dependencies->addClassDependency(Route::class, 'symfony/routing'); $dependencies->addClassDependency(JsonResponse::class, 'symfony/http-foundation'); } }