Upgrade to Pair v4
Goal
Pair v4 moves application code away from implicit ActiveRecord payloads and hidden MVC state toward:
- explicit read models
- immutable request input
- explicit page and JSON responses
The API documentation path follows the same rule: CRUD OpenAPI response schemas now derive from readModel when configured.
Upgrade Tool
Run the upgrader from the application root:
php vendor/viames/pair/scripts/upgrade-to-v4.php --dry-run
php vendor/viames/pair/scripts/upgrade-to-v4.php --write
Inside this repository you can also run:
composer run upgrade-to-v4 -- --dry-run
composer run upgrade-to-v4 -- --write
The upgrader skips .git, node_modules, vendor, and tests folders so it updates application runtime code and package metadata without rewriting external code or test assertions.
What the Upgrader Rewrites Automatically
- controller imports from
Pair\Core\ControllertoPair\Web\Controller, including aliased imports, only when the controller already returns an explicitPageResponse,JsonResponse, orResponseInterface - legacy
_init()hooks toboot()in controllers already migrated to the new response-oriented base - legacy controller
lang()calls to an explicittranslate()helper when the controller is already safe to switch toPair\Web\Controller ApiExposable::apiConfig()blocks that still lack bothreadModelandresource- common
ApiResponse::respond($object->toArray())andUtilities::jsonResponse($object->toArray())patterns by wrapping them throughPair\Data\Payload - readonly
*PageStateskeleton classes insidemodules/*/classes/for legacyViewfiles, including the imports required by the generated code - dedicated warnings for legacy
View::assignState()usage so existing typed state wiring is moved into the controller without generating redundant skeletons - old Runtime Plugin API references to Runtime Extension names, including
PluginInterface,RuntimePluginInterface,registerPlugin(), andregisterRuntimePlugin() - old installable plugin API references to Installable Package names, including
Plugin,PluginBase,InstallablePlugin,installPackage(),downloadPackage(),createManifestFile(),getManifestByFile(),getPlugin(),pluginExists(), andstoreByPlugin() - installable package manifests from
<plugin>nodes to<package>nodes - package-related Composer keywords and known package translation keys
What the Upgrader Reports but Does Not Rewrite Blindly
- controllers that still depend on implicit MVC state such as
setView(),$this->model,$this->view,loadModel(), orgetObjectRequestedById() - legacy
Viewclasses, especially when they still ownpageTitle(),Breadcrumb::path(), or active-menu mutations setView()andassign()/assignState()callsActiveRecord::html()usagereload()flows- Runtime Extension classes whose class name still ends with
Plugin; rename the class and file manually when autoloading permits it
These cases need manual migration because they depend on application-specific state and layout intent.
This rule was validated against the current pair_boilerplate baseline: legacy controllers are now reported, not silently rewritten to an incompatible base class. The same boilerplate validation now generates concrete page-state skeletons for the legacy views, so the manual work can start from explicit code instead of from an empty file.
The framework now also emits deprecation notices in non-production environments when a module still extends Pair\Core\Controller or Pair\Core\View, so the remaining runtime legacy path stays visible during the migration.
Pair v4 native PHP sessions now use an app-scoped cookie name derived from APP_NAME and a cookie path derived from the current URL_PATH, instead of the shared PHPSESSID default. This avoids local multi-application collisions on the same host. Existing browser sessions created with PHPSESSID are not reused after the upgrade, so users may need to log in once.
Documentation Style
Code examples in this document prefer imported class names over fully-qualified type paths.
Use this style:
use Pair\Web\PageResponse;
public function defaultAction(): PageResponse {
// ...
}
Avoid this style in documentation examples:
public function defaultAction(): \Pair\Web\PageResponse {
// ...
}
Fully-qualified names should only be used when they improve clarity or when the surrounding code intentionally has no import section.
Target Pair v4 Shape
Web controller
<?php
use Pair\Web\Controller;
use Pair\Web\PageResponse;
final class UserController extends Controller {
/**
* Run controller setup before actions execute.
*/
protected function boot(): void {}
/**
* Render the default user page with explicit state.
*/
public function defaultAction(): PageResponse {
$user = new User(7);
$state = UserPageState::fromRecord($user);
return $this->page('default', $state, 'User');
}
}
Read model
<?php
use Pair\Data\ArraySerializableData;
use Pair\Data\MapsFromRecord;
use Pair\Data\ReadModel;
use Pair\Orm\ActiveRecord;
final readonly class UserPageState implements ReadModel, MapsFromRecord {
use ArraySerializableData;
/**
* Create the read model from explicit public fields.
*/
public function __construct(
public int $id,
public string $name
) {}
/**
* Map an ORM record to the public read model.
*/
public static function fromRecord(ActiveRecord $record): static {
return new self(
(int)$record->id,
(string)$record->name
);
}
/**
* Export the read model for JSON responses.
*/
public function toArray(): array {
return [
'id' => $this->id,
'name' => $this->name,
];
}
}
API config
<?php
use Pair\Api\ApiExposable;
use Pair\Orm\ActiveRecord;
final class User extends ActiveRecord {
use ApiExposable;
/**
* Return the explicit CRUD API contract.
*/
public static function apiConfig(): array {
return [
'readModel' => UserReadModel::class,
'includes' => ['group'],
'includeReadModels' => ['group' => GroupReadModel::class],
];
}
}
Recommended Manual Migration Order
- Run the upgrader in
--dry-run. - Fix every warning related to legacy controllers,
View,setView(),assign(),html(), andreload(). - Replace
Pair\Data\Payloadbridges with app-specific readonly read models. - Refine the generated
*PageStateskeleton classes by replacingmixedwith concrete application types. - Update layouts to read from the typed
$stateobject. - Update API resources from legacy
resourceadapters toreadModelclasses.
Validation
After the migration:
- run the application test suite
- hit one HTML route using the new
PageResponsepath - hit one JSON route using the new
readModelpath - run
scripts/benchmark-v4.phpto compare common-path costs