Upgrade to v4 - viames/pair GitHub Wiki

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. Legacy Pair\Core\Controller and Pair\Core\View remain available only as migration bridges and emit deprecation notices outside production.

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\Controller to Pair\Web\Controller only when the controller already returns an explicit PageResponse, JsonResponse, or ResponseInterface
  • legacy _init() hooks to boot() in controllers already migrated to the new response-oriented base
  • legacy controller lang() calls to an explicit translate() helper when the controller is already safe to migrate to Pair\Web\Controller
  • ApiExposable::apiConfig() blocks that still lack both readModel and resource
  • common ApiResponse::respond($object->toArray()) and Utilities::jsonResponse($object->toArray()) patterns by wrapping them through Pair\Data\Payload
  • readonly *PageState skeleton classes inside modules/*/classes/ for legacy View files that assign layout variables through assign()
  • old Runtime Plugin API references to Runtime Extension names, including PluginInterface, RuntimePluginInterface, registerPlugin(), and registerRuntimePlugin()
  • old installable plugin API references to Installable Package names, including Plugin, PluginBase, InstallablePlugin, installPackage(), downloadPackage(), createManifestFile(), getManifestByFile(), getPlugin(), pluginExists(), and storeByPlugin()
  • 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(), loadModelForActions(), raiseError(), or getObjectRequestedById()
  • legacy View classes
  • 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() and assign() / 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\Upload usage; migrate manually to Pair\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.

Target Pair v4 Shape

Web controller

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');

	}

}

Read model

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

	}

}

API config

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

	}

}

Recommended Manual Migration Order

  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
  7. replace Pair\Helpers\Upload with Pair\Http\UploadedFile::fromGlobals($fieldName), then use moveTo(...) for local storage or putToS3(...) for S3 storage

Validation

After the migration:

  • run the application test suite
  • hit one HTML route using the new PageResponse path
  • hit one JSON route using the new readModel path
  • run scripts/benchmark-v4.php to compare common-path costs

See also: Pair-v4-Design, Controller, Input, UploadedFile, ReadModel, Payload, PageResponse, JsonResponse, Generator.

⚠️ **GitHub.com Fallback** ⚠️