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 codeclass Book extends BaseBook {}
// 3.0 -- om/BookGenerated.php holds itclass 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 unchanged — BookPeer 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.
Automated migration
Section titled “Automated migration”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:
use Propulsion\Generator\Rector\StubBaseClassToGeneratedTraitRector;use Rector\Config\RectorConfig;
return RectorConfig::configure() ->withPaths([__DIR__ . '/src']) // wherever your model stubs live ->withRules([StubBaseClassToGeneratedTraitRector::class]);composer require --dev rector/rectorvendor/bin/rector process --dry-runvendor/bin/rector processparent::, 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 atBaseObject, not the old generated base. Nothing schema-derived lives onBaseObjectanymore, so this only still works for methodsBaseObjectgenuinely defines itself —__construct()and the lifecycle hooks (preSave(),postSave(),preInsert(), and similar). Aparent::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 viaparent::, 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, aliasinggetAuthorfrom the trait and swapping theparent::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., whateverBookGenerated(or a behavior) defined, as if it had been written directly intoBook. They keep working, unlessBookitself 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 BaseObject — preSave(), 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.
Also removed in 3.0
Section titled “Also removed in 3.0”- The
concrete_inheritancebehavior. 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 declaringconcrete_inheritanceis refused at build time with that guidance, rather than failing obscurely. treeMode="NestedSet", the legacy schema attribute superseded by thenested_setbehavior 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_setbehavior’s Propel-1.4-era method proxies (method_proxiesparameter, 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 whoseclassnamenames a PDO subclass that doesn’t now throws frominitConnection()— at boot, naming the class — instead of surfacing as a confusing failure at the first query that needed a Propulsion-only method. Theconnection.classnamereference covers what a custom class needs; extending the driver-specific class you’re replacing, or usingPropulsionPDOTrait, is the short path. - Application helpers type-hinted
PDOstill work, because every shipped connection class extends the matching\Pdo\*class. But a helper hintedPDOthat receives one of these can no longer be handed a plainPDOfrom Propulsion’s side, and a helper hintedPropulsionPDOis 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’sdelete()/deleteAll()treated afalsereturn from a custom query’spreDelete()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-levelpreDelete()that returnsfalseexpecting the delete to proceed anyway, it’s now blocked. On a soft-deletable table,preDelete()previously ran after thesoft_delete/archivablebehavior’s own code and was unreachable; it now runs first, so its veto also pre-empts a soft delete. nested_setaccessors return one shape instead of two.getChildren(),getSiblings(),getDescendants(),getBranch(), andgetAncestors()return an emptyPropulsionObjectCollectioninstead ofarray()when there’s nothing to return;getFirstChild()/getLastChild()returnnullinstead ofarray(). Code checking=== array()oris_array()against these results needs to check for an empty collection /nullinstead.filterBy<Relation>()raisesTypeError, notPropulsionException, 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. CatchTypeErrorinstead ofPropulsionExceptionif you were relying on that specific exception class.
Worth regenerating for
Section titled “Worth regenerating for”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.