Pop PHP
Introduction

Upgrading from v6

Moving from v6.0.0 to v7.0.0 means 344 backward-compatibility breaks across the bundled components — 127 high, 123 medium, 94 low. Most are edge cases or affect code that extended a component's internals, and a typical application meets a few dozen. This page is the guided version, ordered by what will hurt first; BC-BREAKS.md is the exhaustive one.

Read What's New in v7 alongside this. Several breaks exist because a feature replaced something, and knowing what the replacement is makes the migration shorter.

Before You Start#

Get to PHP 8.4 First#

Every v7 component requires >= 8.4.0, so there's no partial upgrade — you cannot take v7 pop-db on a PHP 8.3 server while the rest of the application stays on v6. Move the runtime, confirm the v6 application still passes its tests there, and only then change Composer constraints.

Do the pop-crypt Re-Encryption Before Anything Is Deployed#

This is the one break that cannot be fixed after the fact. AES-CBC keys are HKDF-derived in v7, so ciphertext written by v6 fails its MAC and will not decrypt. Either decrypt with v6 and re-encrypt with v7 while both are available, or move those fields to aes-256-gcm, which is compatible across both versions. Everything else on this page can be fixed in code after you see it fail; this one cannot.

Know Which Breaks Are Silent#

BC-BREAKS.md grades every entry High, Medium or Low, and the grade is about how likely you are to hit it, not how loud it is when you do:

Severity Meaning
High Fatals, throws, or silently misbehaves in a typical v6 application
Medium Hits a common but not universal feature
Low Edge cases, or only affects code that extended or implemented internals

The ones worth extra attention are the entries described as silent: no error, changed behavior. A fatal on deploy is caught by any smoke test. A route that quietly starts matching DELETE, a mail message that sends with an empty body, or a cache that goes cold are the ones that reach production intact.

Run Your Test Suite Against the v6 Code First, and Keep It Running#

The most useful property of this upgrade is that most breaks are mechanical once you have seen them fail. A suite that passes on v6 and then fails on v7 tells you exactly which of the 344 you met. A suite that was already failing tells you nothing.

Budget for the Deployment-Time Work Separately#

Seven components changed the format of data they persist, so a correctly-migrated codebase deployed onto v6 data still breaks — and mostly does so quietly. Those are listed under Deployment-time data migrations below, and they need action outside your repository.

Framework-Level Changes#

The PHP requirement moves from >= 8.3.0 to >= 8.4.0, on every component at once.

Two Packages Leave the Framework#

popphp/popcorn is dropped because the core absorbed its routing, and popphp/pop-ftp is dropped with no replacement — the guidance is to move that traffic to HTTPS or SSH. Neither will be installed by the v7 metapackage.

Popcorn Is the Migration Every v6 HTTP Application Has to Make#

Popcorn\Pop was the documented HTTP entry point in v6, and Pop\Router\Match\Http has absorbed what it did — method-grouped configs, the fluent verb API, custom verbs and 404-versus-405. What it did not absorb is Popcorn's defaults, and each difference fails silently:

  • A route with no method information is no longer restricted to get,post. The core treats a missing method key as "matches every verb", so every route that was implicitly GET/POST-only becomes reachable by PUT, DELETE and PATCH.
  • The '*' key changed meaning. In Popcorn it was a method group meaning "register these on every method"; in the core it is the wildcard default route. A Popcorn '*' block discards every route inside it and registers a catch-all instead, with no exception raised.
  • Path-prefix keys nesting per-method sub-arrays are not supported. '/users' => ['get' => [...]] registers a route at /usersget/.... Invert it to 'get' => ['/users' => [...]].
  • custom_methods in the config is gone — call addCustomMethods(). any() is gone — a method-less route already matches any verb.
  • setRoute(), setRoutes(), addToAll(), getRoute(), getRoutes($method), hasRoute() and isAllowed() are all gone.

Swapping new Popcorn\Pop(...) for new Pop\Application(...) is the small part. Auditing every route config against that list is the real work, and it's worth doing before anything else in the core section of BC-BREAKS.md. Routing documents the v7 shapes.

Every Component Takes a Major Version Bump#

No breaking change ships behind a minor number, so a caret range on a v6 constraint refuses the v7 release rather than taking it silently. That means you cannot upgrade by accident, and it also means composer update alone will not move you — each constraint has to be raised deliberately.

The Breaks Most Likely to Take Down a v6 App#

A handful of entries out of the 344 account for most of the damage. If you read nothing else in BC-BREAKS.md, read the sections behind these eleven.

  1. Popcorn\Pop is gone, and method-less routes that Popcorn restricted to get,post now match every HTTP verb. Silent. Covered above; see Routing.
  2. Application::run() rethrows every Throwable instead of swallowing Pop\Exception into an app.error event. In v6 the exception was absorbed and the script continued past run(); in v7 it escapes, so every front controller needs a try/catch. Any app.error listener type-hinted Pop\Exception also has to widen to \Throwable, or it raises a TypeError on the first non-Pop exception. See Error Handling.
  3. The model base classes moved packages. Pop\Model\AbstractModel is now Pop\Utils\AbstractModel, Pop\Model\AbstractDataModel is now Pop\Db\Model\AbstractDataModel, and popphp no longer requires pop-db at all — so if you use data models, add popphp/pop-db to your own composer.json explicitly. See Models.
  4. The controller traits were replaced. Pop\Controller\HttpControllerTrait and ConsoleControllerTrait are deleted; the replacements are Pop\Dispatch\HttpTrait and Pop\Dispatch\ConsoleTrait. This is a fatal class-load error rather than a silent one, which makes it the least dangerous item on this list and the one you will hit first. See Controllers.
  5. The router's controller accessors were renamed. getController() became getDispatchable(), addControllerParams() became addDispatchableParams(), and six more, on both Router and Match. There are no aliases, and Router::__call() does not cover them — the old names surface as a catchable Pop\Router\Exception whose message reads like a native PHP fatal, so a catch block somewhere up the stack can hide it.
  6. AES-CBC ciphertext written by v6 cannot be decrypted by v7. Keys are HKDF-derived now. This needs the decrypt-and-re-encrypt pass described under Before you start, run while both versions are still available. See Hashing & Encryption.
  7. Mail part creation moved to pop-mime, and the constructor stopped working. Pop\Mail\Message\Text and Message\Html no longer accept content through the constructor — new Text('Hello') leaves getContent() returning null and sends blank email, with nothing thrown. The replacements are Text::create('Hello') and Html::create('Hello'). See Mail.
  8. Cache keys containing : or / now throw, and every adapter changed its on-backend key format. saveItem('user:1', $v) raises Pop\Cache\InvalidArgumentException ("The cache key contains one or more reserved characters, or is empty"), and because the format changed, the entire cache goes cold on deploy whether or not your keys are legal. See Cache.
  9. Log levels became strings. Logger::ERROR is 'error', not an integer, to match PSR-3. That changes every writer's output format and requires a column migration on the pop_log table. See Logging.
  10. The queue adapter contract was rewritten around leases and a dead-letter store. v6 File-adapter jobs and Database or Redis failed jobs are invisible to a v7 worker. Drain the queue on v6 before upgrading. See Queues & Scheduled Tasks.
  11. Attribute values in pop-dom are now HTML-escaped. Correct by default — and anything you were pre-escaping for v6 now double-encodes, turning & into &. The fix is to stop pre-escaping and pass raw values. Because pop-form and pop-nav render through pop-dom, their output changes too. See DOM.

That's eleven, because the controller traits are worth their own line even though BC-BREAKS.md folds them into the core section rather than listing them in its top ten.

Cross-Cutting Patterns#

Four themes account for most of the 344. Recognizing them makes the per-component sections much faster to read, because you stop reading each entry from scratch and start recognizing which of the four it is.

Strict Types Are Now Declared Everywhere#

Strict mode is decided by the calling file, so your own loosely-typed code is mostly unaffected — but wherever a component passes your loose value into its own strict internals, you get a TypeError you did not get in v6. Real instances from the source document: app_date('Y-m-d', '1755302400') in pop-utils, new Dir($path, ['recursive' => 1]) in pop-dir, Csv::serializeData($d, ['limit' => '3']) in pop-csv, $td->setNodeValue(42) in pop-dom, and the whole Fields::create() config path in pop-form. The pattern is a numeric string or an integer where the component now wants the declared type. Cast at the call site.

Output Escaping Double-Encodes Anything You Escaped Yourself#

pop-dom escapes attribute values, pop-nav escapes node labels, pop-mime RFC 2047-encodes header values, pop-i18n escapes XML, and pop-paginator escapes the request URI. Every one of those is a genuine security fix, and every one of them changes output for code that was already doing the escaping. The v7 rule is: pass raw values and let the component encode once.

Silence Became Exceptions#

pop-storage, pop-auth, pop-cookie, pop-config and pop-dir all replaced "return false / return [] / no-op" with typed exceptions. Code that treated a falsy return as "not found" now propagates a fatal instead of taking the else branch. This is the pattern most likely to turn a working edge case into a 500, because the call site looks correct and the behavior it depended on was never documented.

Some Breaks Cannot Be Fixed in Code at All#

Seven components changed the format of data they persist: cache keys and file layout in pop-cache, queue job directories in pop-queue, a log table column in pop-log, CSRF session shape in pop-form, the session cookie path in pop-session, Azure blob URIs in pop-storage, and AES-CBC ciphertext in pop-crypt. A perfectly migrated codebase deployed onto v6 data still breaks, and mostly does so quietly — a cold cache, a mass logout, jobs that never run. Those are the next section.

Bug Fixes You May Have Coded Around#

Twenty-one of the 344 are corrections rather than redesigns: the v6 behavior was defective, and v7 fixes it. They are still listed as breaks, because a workaround written against a bug stops being correct once the bug is gone. If you never hit the bug, skip them — but if you have a comment in your codebase explaining why something is done the long way around, this is the section to check it against.

Four of them are graded High, and each one is a case where v6 was doing the opposite of what it claimed:

Component What v6 did wrong
pop-console isWindows() returned true on Linux and macOS and false on Windows — every branch that used it was backwards
popphp listeners fired only once; the first trigger() drained the queue, so every later trigger for that name ran nothing
pop-form the required-field check iterated values instead of keys, so validating a subset of fields never enforced anything
pop-storage under chdir() the sub-directory was treated as the container, so blobs were written to the wrong path

The popphp one is worth dwelling on, because a listener that fires once looks like a working listener during development and a broken one under load. If your v6 code re-registered a listener on every request to work around it, remove that — Events covers what v7 actually does with the queue and with priorities.

The remaining seventeen are Medium and Low, spread across pop-code, pop-crypt, pop-db, pop-dir, pop-http, pop-mail, pop-nav, pop-session, pop-utils and pop-view. Each is tagged Bug fix in its component section, and all twenty-one are collected in Bug fixes you may have coded around.

Deployment-Time Data Migrations#

Eight components need action outside your repository, and three of them need it before the new code goes live. These are the ones that make a green test suite misleading: the code is correct, the data underneath it is not.

Component What changed What to do
pop-crypt AES-CBC keys are HKDF-derived; old ciphertext fails its MAC Before upgrading: decrypt with v6 and re-encrypt with v7, or move those fields to aes-256-gcm, which is compatible with both
pop-queue The File adapter moved to pending/ and reserved/; v6 status = 2 failed jobs are unreachable Before upgrading: drain the queue on v6, and delete the orphaned Redis <prefix>:status key
pop-cache The key format changed on every adapter, the File layout is sharded, and Database needs a unique index on key DROP TABLE pop_cache before deploy, and expect a fully cold cache — watch for a thundering herd
pop-log level is now VARCHAR(20), not INT(1), and createTable() will not migrate an existing table ALTER TABLE pop_log MODIFY level VARCHAR(20), then translate the old integer rows
pop-form CSRF session storage went from a serialized string to a per-field array Expect one round of token mismatches; consider forcing session regeneration on deploy
pop-session The cookie path default was fixed — v6 wrote the session lifetime into path Expect a one-time logout of every active session
pop-storage Azure blob URIs were built in the wrong order under chdir() Audit and relocate blobs written by a v6 Azure application that used chdir()
pop-audit Adapter reads now decode old and new; the stored rows are unchanged Remove json_decode() from your read path — no data migration needed

pop-crypt and pop-queue are the two that cannot be done after the fact. Encrypted fields need both versions available at once, and a v6 queue is easier to drain than to translate. Everything else on this table can be done during or after the deploy, at the cost of a visible incident: a cold cache, a mass logout, or log rows that do not read back.

The pop-audit row is the odd one out and is listed because it looks like a data migration and is not. The rows on disk are fine; it's your read path that changes.

Suggested Upgrade Order#

Dependencies flow downward, so upgrade a layer at a time and run that layer's tests before moving on. Doing it all in one composer update works, and it also means every failure you see could have come from any package in the framework.

  1. Foundationpop-utils, pop-color, pop-crypt, pop-config, pop-dom, pop-filter, pop-mime. Do the pop-crypt re-encryption first, before anything is deployed. pop-dom is in this layer rather than with the view components because its escaping change reaches pop-form and pop-nav, and you want to see that break here rather than three layers later.
  2. Corepopphp, then pop-db, pop-http, pop-console. This is where the Popcorn migration, the dispatch renames and the run() rethrow land. Expect this layer to take the longest.
  3. Servicespop-cache, pop-log, pop-session, pop-cookie, pop-storage, pop-queue, pop-debug. Four of the deployment-time data migrations are in this layer, so plan the pop_cache drop, the pop_log column change and the queue drain alongside it.
  4. Application layerpop-acl, pop-auth, pop-audit, pop-validator, pop-form, pop-view, pop-nav, pop-mail, pop-image, pop-pdf, pop-css, pop-csv, pop-i18n, pop-dir, pop-paginator. Most of the double-encoding surprises show up here, because this is where the components that render things live.
  5. Toolingpop-kettle. In each project, re-copy the kettle script, delete kettle.inc.php, and move its PSR-4 line into composer.json. See Kettle.

The order matters most between layers 1 and 2. A pop-utils or pop-mime still on v6 while popphp is on v7 produces failures that look like core bugs and are not.

See Also#

  • What's New in v7 — the same release from the feature side
  • Changelog — the release history, including everything before v7
  • Installation — what a fresh v7 project looks like, for comparison
  • All Components — every package, with its guide and its README