Skip to the content.

Upgrade to Pair v4

Goal

Pair v4 moves application code away from implicit ActiveRecord payloads and hidden MVC state toward:

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

What the Upgrader Reports but Does Not Rewrite Blindly

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],
		];

	}

}
  1. Run the upgrader in --dry-run.
  2. Fix every warning related to legacy controllers, View, setView(), assign(), html(), and reload().
  3. Replace Pair\Data\Payload bridges with app-specific readonly read models.
  4. Refine the generated *PageState skeleton classes by replacing mixed with concrete application types.
  5. Update layouts to read from the typed $state object.
  6. Update API resources from legacy resource adapters to readModel classes.

Validation

After the migration: