Upgrade to v4 - viames/pair GitHub Wiki
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.
Legacy Pair\Core\Controller and Pair\Core\View remain available only as migration bridges and emit deprecation notices outside production.
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 --writeInside this repository you can also run:
composer run upgrade-to-v4 -- --dry-run
composer run upgrade-to-v4 -- --writeThe 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.
- controller imports from
Pair\Core\ControllertoPair\Web\Controlleronly 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 migrate 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 that assign layout variables throughassign() - 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
- controllers that still depend on implicit MVC state such as
setView(),$this->model,$this->view,loadModel(),loadModelForActions(),raiseError(), orgetObjectRequestedById() - legacy
Viewclasses - legacy views that still own controller-side responsibilities such as
pageTitle(),Breadcrumb::path(), or active-menu mutations - legacy views that still load data from
$this->model -
setView()andassign()/assignState()calls -
ActiveRecord::html()usage -
reload()flows - Runtime Extension classes whose class name still ends with
Plugin; rename the class and file manually when autoloading permits it - legacy
Pair\Helpers\Uploadusage; migrate manually toPair\Http\UploadedFile
These cases need manual migration because they depend on application-specific state and layout intent. The upgrader is conservative on purpose: it rewrites only low-risk patterns and reports the rest explicitly.
use Pair\Web\Controller;
final class UserController extends Controller {
/**
* Prepare explicit controller dependencies.
*/
protected function boot(): void {}
/**
* Render the default user page.
*/
public function defaultAction(): \Pair\Web\PageResponse {
$user = new User(7);
$state = UserPageState::fromRecord($user);
return $this->page('default', $state, 'User');
}
}use Pair\Data\ArraySerializableData;
use Pair\Data\MapsFromRecord;
use Pair\Data\ReadModel;
final readonly class UserPageState implements ReadModel, MapsFromRecord {
use ArraySerializableData;
public function __construct(
public int $id,
public string $name
) {}
/**
* Build the read model from the persistence record.
*/
public static function fromRecord(\Pair\Orm\ActiveRecord $record): static {
return new self(
(int)$record->id,
(string)$record->name
);
}
/**
* Export the public user payload.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'name' => $this->name,
];
}
}use Pair\Api\ApiExposable;
final class User extends \Pair\Orm\ActiveRecord {
use ApiExposable;
/**
* Return the explicit CRUD API response contract.
*
* @return array<string, mixed>
*/
public static function apiConfig(): array {
return [
'readModel' => UserReadModel::class,
'includes' => ['group'],
'includeReadModels' => ['group' => GroupReadModel::class],
];
}
}- 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 - replace
Pair\Helpers\UploadwithPair\Http\UploadedFile::fromGlobals($fieldName), then usemoveTo(...)for local storage orputToS3(...)for S3 storage
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
See also: Pair-v4-Design, Controller, Input, UploadedFile, ReadModel, Payload, PageResponse, JsonResponse, Generator.