Smooth way is to use composer, a dependency manager for PHP.
Run composer update and the job is done (almost).
If you are doing a major upgrade, find the upgrade guide down there.
composer updatephp bin/console doctrine:schema:update --force (adds new template column to page table)php bin/console pw:migrate (migrates data from JSON customProperties to new columns, fixes typos)php bin/console cache:cleartemplate is now a real database column (nullable string), no longer stored in customProperties JSON. Getter/setter unchanged: getTemplate() / setTemplate().getSearchExcrept() removed. Use getSearchExcerpt(). In Twig: page.searchExcrept becomes page.searchExcerpt.PageEditorTrait, PageExtendedTrait, PageMainImageTrait, PageOpenGraphTrait, PageSearchTrait, PageRedirectionTrait are removed. Their properties and methods are inlined directly into Page.$h1, $slug, $title, $metaRobots, $name, $editMessage) now use property hooks. Getter/setter methods are retained for caller compatibility.getRedirection() now returns ?PageRedirection value object (or null). Use hasRedirection(), getRedirectionUrl(), getRedirectionCode() instead of accessing the old string-based redirection.getOgTitle(), setOgTitle(), getOgDescription(), etc. are explicit methods (no longer resolved via __call). They still store data in customProperties JSON.__call minimal: Only proxies getCustomProperty() for Twig ergonomics (page.someKey). No method-existence cache, no complex resolution.ImageTrait replaced by ImageData Doctrine embeddable (#[ORM\Embedded(class: ImageData::class, columnPrefix: false)]). DQL queries must use m.imageData.width, m.imageData.height, m.imageData.ratioLabel etc.getMedia() and getName() removed. Use getFileName() and getAlt().$disableRemoveFile removed: The public flag on Media is gone.CustomPropertiesTrait replaced by ExtensiblePropertiesTrait (same JSON column, cleaner API).getSalt() removed (not needed with bcrypt).Replaces CustomPropertiesTrait:
| Old API | New API |
|---|---|
$standAloneCustomProperties | getUnmanagedPropertiesAsYaml() / setUnmanagedPropertiesAsYaml() |
$registeredCustomPropertyFields | registerManagedPropertyKey() / getManagedPropertyKeys() |
Method-existence cache ($methodExistsCache) | Removed |
CustomPropertiesException | InvalidArgumentException |
Core API unchanged: getCustomProperty(), setCustomProperty(), hasCustomProperty(), removeCustomProperty(), getCustomPropertyScalar(), getCustomPropertyList().
| Old | New |
|---|---|
Pushword\Core\Component\App\AppConfig | Pushword\Core\Site\SiteConfig |
Pushword\Core\Component\App\AppPool | Pushword\Core\Site\SiteRegistry + Pushword\Core\Site\RequestContext |
SiteRegistry: Pure registry for site configurations. Methods: get(), getDefault(), findByHost(), getHosts(), getAll(), isKnownHost().RequestContext: Request-scoped state. Methods: switchSite(), setCurrentPage(), getCurrentPage(), requirePage(), getCurrentSite(), getLocale().SiteRegistry also delegates to RequestContext for convenience: switchSite(), setCurrentPage(), getCurrentPage(), getMainHost(), getLocale(), etc.| Old | New |
|---|---|
AppConfig::getView() (with Twig+Cache on config) | TemplateResolver::resolve() (dedicated service) |
SiteConfig::getView() still works (delegates to TemplateResolver).TemplateResolver is a standalone service (Pushword\Core\Template\TemplateResolver) injected via DI.| Old | New |
|---|---|
Duplicated findPage() in PageController and FeedController | PageResolver service |
PageResolver::findPageOr404(): Shared page lookup logic (slug normalization, pager extraction, permission checks).PageResolver::normalizeSlug(): Static method for slug normalization.| Old | New |
|---|---|
Pushword\Core\Component\EntityFilter\Manager | Pushword\Core\Content\ContentPipeline |
Pushword\Core\Component\EntityFilter\ManagerPool | Pushword\Core\Content\ContentPipelineFactory |
ContentPipeline adds explicit typed getters: getMainContent(), getTitle(), getName().ContentPipelineFactory::get(Page) creates pipelines (replaces ManagerPool::getManager()).pw() Twig function is now on ContentPipelineFactory (moved from ManagerPool).Manager in their apply() signature.PushwordEvents catalog class created at Pushword\Core\Event\PushwordEvents with centralized constants: FILTER_BEFORE, FILTER_AFTER, ADMIN_MENU, ADMIN_LOAD_FIELD.FilterEvent, AdminMenuItemsEvent, FormField\Event) now reference PushwordEvents constants.standAloneCustomProperties form field binding changed to unmanagedPropertiesAsYaml.searchExcrept form field binding changed to searchExcerpt.| Old | New |
|---|---|
page.searchExcrept | page.searchExcerpt |
Other Twig access patterns unchanged: page.ogTitle, page.h1, page.slug, page.someCustomKey all work as before.
The following protected methods have been removed from the Media entity and moved to a new MediaFileName utility class:
extractExtension()slugifyPreservingExtension()If you have a custom Media subclass that calls or overrides these methods:
Before:
$extension = $this->extractExtension($filename);
$slugified = $this->slugifyPreservingExtension($filename, $extension);
After:
use Pushword\Core\Utils\MediaFileName;
$extension = MediaFileName::extractExtension($filename);
$slugified = MediaFileName::slugifyPreservingExtension($filename, $extension);
Media::getDimensions() and the image_dimensions() Twig function now return a Dimensions object instead of an array.
PHP Code Changes:
// Before
$dimensions = $media->getDimensions();
$width = $dimensions[0];
$height = $dimensions[1];
// After
$dimensions = $media->getDimensions();
$width = $dimensions->width;
$height = $dimensions->height;
// Or use toArray() for backward compatibility:
$arr = $dimensions->toArray(); // [width, height]
Twig Template Changes:
{# Before #}
{% set width = image_dimensions(image)[0] %}
{% set height = image_dimensions(image)[1] %}
{# After #}
{% set width = image_dimensions(image).width %}
{% set height = image_dimensions(image).height %}
The pushword/installer package is no longer removed after initial project setup. It now stays as a dependency to support automatic setup when adding new Pushword packages via composer require.
If you have orphaned scripts in your composer.json referencing Pushword\Installer classes that cause errors:
pushword/installer to your dependencies: composer require pushword/installer
composer.json:"scripts": {
"post-install-cmd": ["@auto-scripts"],
"post-update-cmd": ["@auto-scripts"]
}
And remove post-autoload-dump if it only contains Pushword\Installer\PostAutoloadDump::runPostAutoload.The conversation form route has changed from path-based to query-based parameters for host and locale.
Before: /conversation/{type}/{referring}/{host}/{locale}
After: /conversation/{type}/{referring}?host=...&locale=...
If you have custom templates or JavaScript that generates conversation URLs manually, update them to use query parameters instead of path segments.
The email notification system has been unified with a new NotificationEmailSender service. If you extended or customized email sending in your code:
Configuration Changes:
Two new global config keys have been added that serve as defaults for all notification services:
pushword:
notification_email_from: 'noreply@example.com'
notification_email_to: 'admin@example.com'
These global defaults are used when package-specific keys (conversation_notification_email_from, page_update_notification_from, etc.) are not set.
Code Changes (only if you extended these services):
MagicLinkMailer now uses NotificationEmailSender instead of MailerInterfaceNewMessageMailNotifier now uses NotificationEmailSender instead of MailerInterfacePageUpdateNotifier now uses NotificationEmailSender instead of MailerInterface + TwigSee Template Change
The MainContentSplitter filter has been removed from the default configuration. The pw(page).mainContent now returns a string (processed HTML) instead of a SplitContent object.
If you use custom templates that access pw(page).mainContent.chapeau, pw(page).mainContent.body, etc., you need to update them:
Before:
{{ pw(page).mainContent.chapeau|raw }}
{{ pw(page).mainContent.body|raw }}
{{ pw(page).mainContent.toc|raw }}
{% for part in pw(page).mainContent.contentParts %}
{{ part|raw }}
{% endfor %}
After:
{% set mainContent = mainContentSplit(page) %}
{{ mainContent.chapeau|raw }}
{{ mainContent.body|raw }}
{{ mainContent.toc|raw }}
{% for part in mainContent.contentParts %}
{{ part|raw }}
{% endfor %}
For simple templates that just need the full content without splitting:
{{ pw(page).mainContent|raw }}
The new mainContentSplit(page) function caches results by page ID, so you can call it multiple times without performance penalty.
sed -i "s|#resource: '@FrameworkBundle/Resources/config/routing/errors.xml'|resource: '@FrameworkBundle/Resources/config/routing/errors.php'|g" ./config/routes/dev/framework.yaml \
; sed -i "s|resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'|resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'|g" ./config/routes/dev/web_profiler.yaml \
; sed -i "s|resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'|resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'|g" ./config/routes/dev/web_profiler.yaml
import { initShowMore } from '@pushword/js-helper/src/ShowMore.js'
initShowMore()
php bin/console pw:image:cache to regenerate cached images.static/%main_host% directory instead of %main_host%.config/routes.yaml, replace resource: "@PushwordCoreBundle/Resources/config/routes/all.yaml" by resource: "@PushwordCoreBundle/Resources/config/routes.yaml"sed -i "s|@PushwordCoreBundle/Resources/config/routes/all.yaml|@PushwordCoreBundle/Resources/config/routes.yaml|g" ./config/routes.yaml
config/packages and compare it with the new one in vendor/pushword/skeleton/config/packages - flex add tons of config but you need to maintain them. Best practice is to remove theme and to keep framework.yaml (you can easily compare with the maintained one in the skeleton), pentatrion.yaml, twig.yaml, web_profiler.yaml, pushword.yaml .templates/base.html.twig file, if yes, remove it.rm media/*.{yaml,json} (we are now using a global index.csv)config/bundles.php the bundles related to Sonata : Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
Sonata\Form\Bridge\Symfony\SonataFormBundle::class => ['all' => true],
Sonata\Twig\Bridge\Symfony\SonataTwigBundle::class => ['all' => true],
Sonata\BlockBundle\SonataBlockBundle::class => ['all' => true],
Sonata\AdminBundle\SonataAdminBundle::class => ['all' => true],
Sonata\DoctrineORMAdminBundle\SonataDoctrineORMAdminBundle::class => ['all' => true],
config/packages/ directory (searched for sonata)The default installation is now creating. Look in vendor/pushword/core/install.php how to implement it in your project.
Scan all files inside the ./templates directory.
Identify and update every Tailwind CSS class name from version 3 syntax to version 4 syntax, ensuring full compatibility with Tailwind 4’s new naming conventions and features.
https://tailwindcss.com/docs/upgrade-guide#changes-from-v3`)
./assets/webpack.config.js to ./vite.config.jsSee vendor/pushword/skeleton/vite.config.js./assets/package.json to ./package.jsoncomposer require pentatrion/vite-bundleconfig/packages/pushword.yaml# FROM
...
assets:
{
javascripts: ["/assets/app.js?6"],
stylesheets: ["/assets/style.css?65"],
},
# TO (adapt with your vite.config.js output)
assets:
{
vite_javascripts: ["app"],
vite_stylesheets: ["theme"],
},
app.css, upgrade it to tailwind v4 (see vendor/pushword/js-helper/src/app.css) - be careful with the @source paths.config/packages/, must be like this :pentatrion_vite:
build_directory: assets
Notes :
php bin/console pw:json-to-markdown --dry-run
php bin/console pw:json-to-markdown
# by host
php bin/console pw:json-to-markdown --host=example.com
# or by page id
php bin/console pw:json-to-markdown --page-id=123
App\Entity\*bin/console doctrine:schema:update --forcecomposer remove pushword/svg (and from config/bundles.php)getStylesheets or getJavascriptpage_position for breadcrumb_list_position