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.

Pair v4 remember-me tokens are now device-scoped. Creating or renewing a remember-me cookie no longer deletes the user’s other remember-me records, and logout removes only the token represented by the current browser cookie. Applications that previously relied on login from one browser to revoke remembered access from all other browsers should add an explicit account-level revocation flow.

Pair v4 also ships reusable native mobile stacks for apps that use Pair APIs. mobile/ios/PairMobileKit and mobile/android/PairMobileAndroid standardize cookie-free transport, Bearer auth with remember_me=true, short-lived access tokens, optional persistent refresh tokens, migratable local storage, verified startup bootstrap, single-flight refresh, and remote image caching. The PHP API layer includes default mobile auth endpoints under Pair\Api\ApiController::authAction(), backed by api_tokens and Pair\Models\ApiToken; apply the Pair auth migrations, including migrations/20260510_api_tokens.sql and migrations/20260510_api_tokens_device_metadata.sql, and review PAIR_MOBILE_ACCESS_TOKEN_LIFETIME / PAIR_MOBILE_REFRESH_TOKEN_LIFETIME before enabling native apps. Use Pair\Api\OpenApi\SpecGenerator::addMobileAuthPaths() to publish the mobile auth contract in OpenAPI. See docs/MOBILE_AUTH_APP_SETUP.md, docs/MOBILE_IOS_STACK.md, and docs/MOBILE_ANDROID_STACK.md before wiring a native app directly to custom auth code.

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: