Skip to content

Upgrading from 2.x to 3.0

Propulsion 3.0’s headline change is how generated Object Model, Query, and Node code attaches to your stub classes — a trait your class uses, rather than a base class it extends. That single change is what let generated-code PHPStan level 9 findings drop from 550 to 0. Alongside it, 3.0 removes the concrete_inheritance behavior and the legacy treeMode="NestedSet" schema attribute, and fixes three generated-code faults that are real behavior changes rather than pure bug fixes.

If you’re coming from Propel 1 rather than Propulsion 2.x, start at Migrating from Propel 1 instead — it assumes 3.0 throughout.

Generated code is a trait, not a base class

Section titled “Generated code is a trait, not a base class”

Generated object, query, and node code is now emitted as a trait that your stub class uses, rather than a base class it extends:

// 2.x -- om/BaseBook.php held the generated code
class Book extends BaseBook {}
// 3.0 -- om/BookGenerated.php holds it
class Book extends BaseObject implements Persistent, Poolable, WritableModelInterface
{
use BookGenerated;
}

The same shape change applies to query classes (BookQuery extends ModelCriteria { use BookQueryGenerated; }) and node classes for nested_set tables. Peers are unchangedBookPeer extends BaseBookPeer still extends a generated base, because peer methods are all static and never had a $this to mistype.

Why. Every relation call the generator emits passes $this — for instance $review->setBook($this), where setBook() takes a Book. Written into BaseBook, $this was typed as BaseBook, so the call only held at runtime because the stub happened to be the base’s only subclass — nothing stated or enforced that. A hand-written class Rogue extends BaseBook got a TypeError. PHPStan analyses a trait body once per using class, so inside BookGenerated, $this is Book, and the premise stops being an assumption.

Your stub classes are generated once and then owned by you, so regenerating does not update them. Upgrade the library and regenerate first, so the traits exist, then run the migration Rector rule Propulsion ships, Propulsion\Generator\Rector\StubBaseClassToGeneratedTraitRector:

rector.php
use Propulsion\Generator\Rector\StubBaseClassToGeneratedTraitRector;
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src']) // wherever your model stubs live
->withRules([StubBaseClassToGeneratedTraitRector::class]);
Terminal window
composer require --dev rector/rector
vendor/bin/rector process --dry-run
vendor/bin/rector process

parent::, self::, static::, and $this-> no longer all mean the same thing

Section titled “parent::, self::, static::, and $this-> no longer all mean the same thing”

This is the change most likely to bite you silently, because the wrong code doesn’t fail to compile — it either resolves to the wrong place or recurses forever. Read this section even if the Rector rule handles your codebase automatically.

A trait is flattened into the class that uses it; a base class isn’t. That difference changes what each call form reaches once Book uses BookGenerated instead of extending BaseBook:

  • parent::someMethod() now looks at BaseObject, not the old generated base. Nothing schema-derived lives on BaseObject anymore, so this only still works for methods BaseObject genuinely defines itself — __construct() and the lifecycle hooks (preSave(), postSave(), preInsert(), and similar). A parent:: call into anything generated — a column accessor, a relation setter, a behavior-added method — no longer resolves. This is by far the most common shape in real codebases: an override that wraps a generated accessor or mutator with extra logic and then defers to the original via parent::, for example:

    class Book extends BaseBook
    {
    public function getAuthor()
    {
    do_stuff();
    do_more_stuff();
    return parent::getAuthor();
    }
    }

    Every override written like this — regardless of what runs before the parent:: call, or how many of them a class has — is exactly what the Rector rule rewrites automatically, aliasing getAuthor from the trait and swapping the parent:: call for the alias. You don’t need to hand-audit these; run the migration and let it do the rewrite.

  • self::someMethod(), static::someMethod(), and $this->someMethod() all see the flattened method — i.e., whatever BookGenerated (or a behavior) defined, as if it had been written directly into Book. They keep working, unless Book itself declares an override with that same name — in which case the call goes right back into your own override, not into the generated version underneath it.

The Rector rule handles the parent:: case for you, and only where it’s actually needed. It does not rewrite a self::/static::/$this-> call into itself calling itself — that shape compiles and looks correct, so there’s nothing for a mechanical migration to flag. Audit any stub that overrides a generated method and calls its own name recursively, by hand, after running the migration.

Calls to methods that are really declared on BaseObjectpreSave(), postSave(), preInsert(), and the other lifecycle hooks — keep working through parent:: and are left untouched. That distinction matters: PHP rejects an alias for a method the trait doesn’t define (“An alias (x) was defined for method foo(), but this method does not exist”), so aliasing a hook would be a hard fatal at compile time.

A behavior-added method (anything a bundled or custom behavior injects via objectMethods()) is generated, not inherited from BaseObject — a parent:: call into one needs the same aliasing treatment. versionable’s isVersioningNecessary() is one example.

What the rule leaves alone, by design: peer stubs; any class that isn’t a stub sitting directly on its own generated base (class X extends BaseX); stubs whose new parent can’t be resolved, since classifying parent:: calls without the parent’s method list would be guesswork and a wrong guess fails at compile time; and parent:: inside a closure or nested anonymous class, which binds to a different scope.

  • The concrete_inheritance behavior. It copied every parent column into the child table and chained the generated classes through your stubs. Model the relationship with a foreign key to the parent table, or use single-table inheritance (<column ... inheritance="single">). A schema still declaring concrete_inheritance is refused at build time with that guidance, rather than failing obscurely.
  • treeMode="NestedSet", the legacy schema attribute superseded by the nested_set behavior six minor versions ago. A table still declaring it is refused at build time, naming the behavior to use instead. treeMode="MaterializedPath" is untouched.
  • The nested_set behavior’s Propel-1.4-era method proxies (method_proxies parameter, and the ten aliases it generated — createRoot(), retrieveParent(), getNumberOfChildren(), and similar). Each has had a current name (makeRoot(), getParent(), countChildren(), …) since Propulsion 1.5; the proxy and its parameter are gone.

A connection is now a PropulsionPDO, checked once

Section titled “A connection is now a PropulsionPDO, checked once”

getConnection(), getReadConnection(), getWriteConnection() and getMasterConnection() return PropulsionPDO rather than PDO|PropulsionPDO, and the check that it is one happens once, where the connection is built, instead of being re-asserted at every use site. That let 51 downstream instanceof guards go away, 39 of them from generated code.

Two things to check:

  • A custom connection class must implement Propulsion\Connection\PropulsionPDO. A datasource whose classname names a PDO subclass that doesn’t now throws from initConnection() — at boot, naming the class — instead of surfacing as a confusing failure at the first query that needed a Propulsion-only method. The connection.classname reference covers what a custom class needs; extending the driver-specific class you’re replacing, or using PropulsionPDOTrait, is the short path.
  • Application helpers type-hinted PDO still work, because every shipped connection class extends the matching \Pdo\* class. But a helper hinted PDO that receives one of these can no longer be handed a plain PDO from Propulsion’s side, and a helper hinted PropulsionPDO is now the more accurate signature.

Three behavior changes to check your code against

Section titled “Three behavior changes to check your code against”

Three generated-code faults were fixed as part of the 3.0 work, and each is a real behavior change rather than a pure bug fix — code that depended on the old (wrong) behavior needs updating:

  • A query-level preDelete() veto now actually vetoes. ModelCriteria’s delete()/deleteAll() treated a false return from a custom query’s preDelete() hook as “nothing happened, carry on,” so a hook written to block a deletion was silently ignored. It now stops the delete, as the method’s own contract always implied. If you have a query-level preDelete() that returns false expecting the delete to proceed anyway, it’s now blocked. On a soft-deletable table, preDelete() previously ran after the soft_delete/archivable behavior’s own code and was unreachable; it now runs first, so its veto also pre-empts a soft delete.
  • nested_set accessors return one shape instead of two. getChildren(), getSiblings(), getDescendants(), getBranch(), and getAncestors() return an empty PropulsionObjectCollection instead of array() when there’s nothing to return; getFirstChild()/getLastChild() return null instead of array(). Code checking === array() or is_array() against these results needs to check for an empty collection / null instead.
  • filterBy<Relation>() raises TypeError, not PropulsionException, for a wrong-typed argument. The parameter is now natively typed (Model|PropulsionObjectCollection, matching what the docblock always said), so PHP itself rejects the wrong shape. Catch TypeError instead of PropulsionException if you were relying on that specific exception class.

Regenerate (bin/propulsion model:build) before running the Rector rule above — it rewrites stubs to use a trait that has to already exist. Everything else in this release is generator/runtime behavior, so a rebuild is what actually picks it up; there’s no opt-in flag to postpone it behind.