pushword/page-scanner
Pushword Page Scanner helps you crawl and inspect your site’s pages, making it easy to surface broken links, missing content, and other page-level issues so you can keep your website clean, reliable, and user-friendly.
pushword/page-scanner is a bundle inside the Pushword monorepo (packages/page-scanner), not a standalone library. Two entry points, depending on which side of it you are on:
Downstream site (the common case)
composer require pushword/page-scanner
Then register the bundle in config/bundles.php, and — because Pushword ships no migrations — push the schema:
bin/console doctrine:schema:update --force
bin/console cache:clear
SQLite is the default; PostgreSQL and MariaDB are supported via Doctrine. If you are evaluating the package, start from packages/dev-app/, where the bundle is already wired and the demo content is seeded:
composer install
composer reset-dev-app # reseed the demo
composer dev # symfony server:start
composer console list pw # every Pushword command, including the scanner's
First look, in this order
packages/page-scanner/src/ — the bundle class, DependencyInjection/, Command/, Entity/, Service/.packages/page-scanner/tests/ — the tests are the most accurate description of behaviour the package has.packages/docs/content/extension/ — the per-bundle feature page, if one has been written.packages/core/src/Event/PushwordEvents.php — the core and admin event names you can hook a scan onto.packages/docs/content/architecture.md and extensions.md only if you need repository-wide context. Do not read further than the task requires.First use case: run the scanner against the demo app to see its output shape, then decide whether you want it as a console command in CI, as a post-save hook, or surfaced in the admin. Run it with --format=text when you want human-readable output — agent-optimised commands collapse to a single JSON line when an AI agent is detected.
Reuse the scanner as a service, not a command. The command in src/Command/ should be a thin shell over a service you can call from a controller, an event listener, or a scheduler. This is the single most valuable pattern in a CMS bundle: the command gives you a manual trigger, the service gives you every other integration.
Hook scans onto the page lifecycle. PushwordEvents in packages/core/src/Event/PushwordEvents.php is the supported seam — a kernel or domain listener that runs the scan after a page is saved keeps the admin experience honest without forking the admin controller. Respect the bundle's ordering: if the scan reads rendered HTML, it must run after the fragment/render cache is warm, not before.
Persist results as a Doctrine entity; do not invent a migration. Pushword's convention is doctrine:schema:update --force. Add the entity, add the relation to Page, and let the schema updater do the work. Anything you store becomes part of the site's schema, so keep scan findings denormalised enough that a scan row can be deleted without cascading into content.
Surface findings in the admin via {% block page_actions %}. If you add a "Rescan" button to the page detail screen, the block name matters: in templates extending @EasyAdmin/page/content.html.twig, a wrongly-named block is silently dropped — route still returns 200, the button is simply absent. Assert on rendered HTML in tests, never on the status code alone.
Follow the provider/tag registration seam for anything pluggable. The monorepo consistently activates extensions by shipping a PHP interface implementation tagged on a pushword.* tag, which a *Extension class merges in. If you need to add a checker (SEO, broken links, heading order), register it that way rather than hard-coding a list — it matches the pattern the rest of the codebase uses and survives the next release.
Agent-friendly commands. If you add a command, give it Pushword\Core\Command\AgentOutputTrait and a --format option (auto|agent|text), gating every human write behind if (! $this->agentMode). Then update packages/docs/content/agent-output.md — not AGENTS.md, which lists the which, not the what.
Cron / CI scans. A console command is the natural fit for a nightly scan across all pages. Keep the scan's output out of the byte-deterministic render path — the static build's skip-unchanged logic will rebuild every page if scan output carries a timestamp or ordering that shifts between runs.
Testing. composer test-filter PageScannerTest, never vendor/bin/phpunit. Never markTestSkipped to make a red test green. Run composer stan and composer rector alongside. Human-output assertions must pass '--format' => 'text' or they will compare against the agent JSON line.
packages/docs/content/upgrade/ directory holds one note per release; upgrade/next-release.md is the live one and upgrade.md indexes them. Read it before bumping — the package has no ecosystem of blog posts to fall back on.cache:clear does not clear everything. var/cache/pushword-pools (the Markdown fragment pool) survives deploys on purpose. If your change alters rendered output, bump MarkdownParser::CACHE_VERSION (SplitContent::TOC_CACHE_VERSION for the TOC) or you will debug a cache that never misses.random() in a render path — the static build's skip-unchanged and the content-hash cache both assume renders are reproducible. A content-hash cache that never hits means entropy leaked into the output; treat a 100% miss rate as a bug, not a warmup cost.bin/console runs from packages/dev-app/, composer scripts from the repo root. Mixing them up produces "command not found" that looks like a broken install.LIKE case-sensitivity all differ; if the scanner stores timestamps or matches URLs, test on the database you will deploy.SYMFONY_DEPRECATIONS_HELPER='max[self]=0&max[direct]=0' composer test for runtime, php bin/console debug:container --deprecations for the container. A passing suite with deprecation noise is a broken build here.page_actions for header buttons; actions loses them without an error. Verify in the isolated test browser (dev-browser --browser agent-dedicated, discover via page.snapshotForAI(), then act by node id) rather than eyeballing a screenshot.git commit --only -m "type(scope): subject" -- <paths>, with a scoped git add <path> first for new files. Never commit -a, add -A, stash, or hard-reset. .claude/hooks/guard-git.sh string-matches the whole Bash command, so a blocked pattern inside a commit message has to run from a script file.total - errors.packages/<package>/translations/messages.<locale>.yaml, camelCase keys, alphabetical. Twig i18n keys that drift from that ordering get re-sorted by the next contributor and show up as noise in your diff.How can I help you explore Laravel packages today?