Files
WP-Content-Sync/docs/superpowers/plans/2026-04-26-wordpress-content-sync-foundation.md
T
2026-04-26 12:44:16 -05:00

32 KiB

WordPress Content Sync Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the loadable WordPress plugin foundation for WP Content Sync with tooling, bootstrap, settings, logging, and an admin dashboard shell.

Architecture: The foundation uses a small bootstrap file at the plugin root and focused PHP classes under src/. A service container wires settings, logging, activation hooks, and admin screens while keeping WordPress calls at clear boundaries so unit tests can cover the domain behavior without a full WordPress runtime.

Tech Stack: PHP 7.4+, WordPress 5.6+, Composer, PHPUnit, PHPStan level 6+, PHP_CodeSniffer with the project phpcs.xml, WordPress Coding Standards.


File Structure

  • Create: wp-content-sync.php as the WordPress plugin entrypoint.
  • Create: composer.json for autoloading, dev dependencies, and scripts.
  • Create: phpcs.xml for WordPress-oriented coding standards.
  • Create: phpstan.neon for level 6 static analysis.
  • Create: phpunit.xml.dist for unit test configuration.
  • Create: src/Plugin.php as the lifecycle coordinator.
  • Create: src/Container.php as the lightweight service registry.
  • Create: src/Activator.php to install default options on activation.
  • Create: src/Deactivator.php to perform safe deactivation cleanup.
  • Create: src/Settings/Settings.php as a typed settings value object.
  • Create: src/Settings/SettingsRepository.php as the WordPress option boundary.
  • Create: src/Logging/LoggerInterface.php as the logging boundary.
  • Create: src/Logging/OptionLogger.php as the initial bounded option-backed logger.
  • Create: src/Admin/AdminPage.php as the admin dashboard controller.
  • Create: templates/admin/dashboard.php as the escaped dashboard view.
  • Create: tests/bootstrap.php for PHPUnit bootstrap and WordPress function stubs.
  • Create: tests/Unit/SettingsTest.php for settings defaults and sanitization behavior.
  • Create: tests/Unit/ContainerTest.php for service registration behavior.
  • Create: tests/Unit/OptionLoggerTest.php for log redaction and retention behavior.

Task 1: Add Composer and Quality Tooling

Files:

  • Create: composer.json

  • Create: phpcs.xml

  • Create: phpstan.neon

  • Create: phpunit.xml.dist

  • Step 1: Create Composer metadata and scripts

Create composer.json:

{
  "name": "ksolo/wp-content-sync",
  "description": "Bidirectional WordPress content synchronization plugin.",
  "type": "wordpress-plugin",
  "license": "GPL-2.0-or-later",
  "require": {
    "php": ">=7.4"
  },
  "require-dev": {
    "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
    "phpstan/phpstan": "^1.10",
    "phpunit/phpunit": "^9.6",
    "squizlabs/php_codesniffer": "^3.9",
    "wp-coding-standards/wpcs": "^3.0"
  },
  "autoload": {
    "psr-4": {
      "WPContentSync\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "WPContentSync\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "validate": "composer validate --strict",
    "lint": "phpcs",
    "lint:fix": "phpcbf",
    "stan": "phpstan analyse",
    "test": "phpunit"
  },
  "config": {
    "allow-plugins": {
      "dealerdirect/phpcodesniffer-composer-installer": true
    },
    "sort-packages": true
  }
}
  • Step 2: Create PHPCS rules

Create phpcs.xml:

<?xml version="1.0"?>
<ruleset name="WP Content Sync">
	<description>Custom coding standards for WP Content Sync.</description>
	<arg name="basepath" value="."/>
	<arg name="extensions" value="php"/>
	<arg name="parallel" value="8"/>
	<file>wp-content-sync.php</file>
	<file>src</file>
	<file>tests</file>
	<rule ref="WordPress">
		<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
		<exclude name="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound"/>
	</rule>
	<rule ref="WordPress-Docs"/>
	<rule ref="WordPress-Extra"/>
	<config name="minimum_supported_wp_version" value="5.6"/>
	<config name="testVersion" value="7.4-"/>
	<exclude-pattern>vendor/*</exclude-pattern>
</ruleset>
  • Step 3: Create PHPStan configuration

Create phpstan.neon:

parameters:
  level: 6
  paths:
    - src
    - wp-content-sync.php
  scanFiles:
    - tests/bootstrap.php
  bootstrapFiles:
    - tests/bootstrap.php
  • Step 4: Create PHPUnit configuration

Create phpunit.xml.dist:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php" colors="true" beStrictAboutTestsThatDoNotTestAnything="true">
	<testsuites>
		<testsuite name="Unit">
			<directory>tests/Unit</directory>
		</testsuite>
	</testsuites>
</phpunit>
  • Step 5: Install dependencies

Run: composer install

Expected: Composer installs dev dependencies and generates vendor/autoload.php.

  • Step 6: Validate Composer metadata

Run: composer validate --strict

Expected: PASS with no schema or lock warnings after composer install.

  • Step 7: Commit
git add composer.json composer.lock phpcs.xml phpstan.neon phpunit.xml.dist
git commit -m "chore: add php quality tooling"

Task 2: Add PHPUnit Bootstrap and Core Test Scaffolding

Files:

  • Create: tests/bootstrap.php

  • Create: tests/Unit/ContainerTest.php

  • Create: src/Container.php

  • Step 1: Write the failing container tests

Create tests/Unit/ContainerTest.php:

<?php

namespace WPContentSync\Tests\Unit;

use PHPUnit\Framework\TestCase;
use WPContentSync\Container;

class ContainerTest extends TestCase {
	public function test_it_returns_registered_service(): void {
		$container = new Container();
		$service   = new \stdClass();

		$container->set( 'example', $service );

		self::assertSame( $service, $container->get( 'example' ) );
	}

	public function test_it_reuses_factory_result(): void {
		$container = new Container();
		$calls     = 0;

		$container->factory(
			'example',
			static function () use ( &$calls ): \stdClass {
				++$calls;
				return new \stdClass();
			}
		);

		$first  = $container->get( 'example' );
		$second = $container->get( 'example' );

		self::assertSame( $first, $second );
		self::assertSame( 1, $calls );
	}

	public function test_it_throws_for_unknown_service(): void {
		$container = new Container();

		$this->expectException( \InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Service "missing" is not registered.' );

		$container->get( 'missing' );
	}
}
  • Step 2: Add test bootstrap with WordPress stubs

Create tests/bootstrap.php:

<?php

require_once dirname( __DIR__ ) . '/vendor/autoload.php';

if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', dirname( __DIR__ ) . '/' );
}

if ( ! function_exists( 'sanitize_text_field' ) ) {
	function sanitize_text_field( $value ) {
		return trim( preg_replace( '/[\r\n\t]+/', ' ', strip_tags( (string) $value ) ) );
	}
}

if ( ! function_exists( 'esc_html' ) ) {
	function esc_html( $value ) {
		return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
	}
}

if ( ! function_exists( 'esc_attr' ) ) {
	function esc_attr( $value ) {
		return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
	}
}

if ( ! function_exists( 'esc_url' ) ) {
	function esc_url( $value ) {
		return filter_var( (string) $value, FILTER_SANITIZE_URL );
	}
}
  • Step 3: Run test to verify it fails

Run: composer test -- --filter ContainerTest

Expected: FAIL because class WPContentSync\Container does not exist.

  • Step 4: Implement the container

Create src/Container.php:

<?php

namespace WPContentSync;

final class Container {
	/**
	 * @var array<string, mixed>
	 */
	private array $services = array();

	/**
	 * @var array<string, callable(): mixed>
	 */
	private array $factories = array();

	public function set( string $id, $service ): void {
		$this->services[ $id ] = $service;
	}

	/**
	 * @param callable(): mixed $factory
	 */
	public function factory( string $id, callable $factory ): void {
		$this->factories[ $id ] = $factory;
	}

	public function get( string $id ) {
		if ( array_key_exists( $id, $this->services ) ) {
			return $this->services[ $id ];
		}

		if ( array_key_exists( $id, $this->factories ) ) {
			$this->services[ $id ] = $this->factories[ $id ]();
			return $this->services[ $id ];
		}

		throw new \InvalidArgumentException( sprintf( 'Service "%s" is not registered.', $id ) );
	}
}
  • Step 5: Run test to verify it passes

Run: composer test -- --filter ContainerTest

Expected: PASS with 3 tests.

  • Step 6: Commit
git add src/Container.php tests/bootstrap.php tests/Unit/ContainerTest.php
git commit -m "test: add service container coverage"

Task 3: Add Settings Value Object and Repository

Files:

  • Create: src/Settings/Settings.php

  • Create: src/Settings/SettingsRepository.php

  • Create: tests/Unit/SettingsTest.php

  • Step 1: Write failing settings tests

Create tests/Unit/SettingsTest.php:

<?php

namespace WPContentSync\Tests\Unit;

use PHPUnit\Framework\TestCase;
use WPContentSync\Settings\Settings;

class SettingsTest extends TestCase {
	public function test_it_provides_secure_defaults(): void {
		$settings = Settings::fromArray( array() );

		self::assertSame( array(), $settings->syncPairs() );
		self::assertSame( 'warning', $settings->loggingLevel() );
		self::assertTrue( $settings->automaticUrlReplacementEnabled() );
		self::assertSame( 'last_write_wins', $settings->conflictStrategy() );
	}

	public function test_it_sanitizes_scalar_settings(): void {
		$settings = Settings::fromArray(
			array(
				'logging_level'      => '<b>debug</b>',
				'conflict_strategy'  => "manual_review\n",
				'automatic_url_replacement' => false,
			)
		);

		self::assertSame( 'debug', $settings->loggingLevel() );
		self::assertSame( 'manual_review', $settings->conflictStrategy() );
		self::assertFalse( $settings->automaticUrlReplacementEnabled() );
	}

	public function test_it_rejects_unknown_logging_level(): void {
		$settings = Settings::fromArray(
			array(
				'logging_level' => 'verbose',
			)
		);

		self::assertSame( 'warning', $settings->loggingLevel() );
	}

	public function test_it_serializes_to_array(): void {
		$settings = Settings::fromArray(
			array(
				'sync_pairs' => array(
					array(
						'name'            => 'Staging',
						'source_url'      => 'https://example.test',
						'destination_url' => 'https://staging.example.test',
					),
				),
			)
		);

		self::assertSame(
			array(
				'sync_pairs'                 => array(
					array(
						'name'            => 'Staging',
						'source_url'      => 'https://example.test',
						'destination_url' => 'https://staging.example.test',
					),
				),
				'logging_level'             => 'warning',
				'automatic_url_replacement' => true,
				'conflict_strategy'         => 'last_write_wins',
			),
			$settings->toArray()
		);
	}
}
  • Step 2: Run test to verify it fails

Run: composer test -- --filter SettingsTest

Expected: FAIL because class WPContentSync\Settings\Settings does not exist.

  • Step 3: Implement settings value object

Create src/Settings/Settings.php:

<?php

namespace WPContentSync\Settings;

final class Settings {
	private const LOGGING_LEVELS = array( 'error', 'warning', 'info', 'debug' );
	private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' );

	/**
	 * @var array<int, array{name: string, source_url: string, destination_url: string}>
	 */
	private array $sync_pairs;

	private string $logging_level;
	private bool $automatic_url_replacement;
	private string $conflict_strategy;

	/**
	 * @param array<int, array{name: string, source_url: string, destination_url: string}> $sync_pairs
	 */
	private function __construct(
		array $sync_pairs,
		string $logging_level,
		bool $automatic_url_replacement,
		string $conflict_strategy
	) {
		$this->sync_pairs                  = $sync_pairs;
		$this->logging_level               = $logging_level;
		$this->automatic_url_replacement   = $automatic_url_replacement;
		$this->conflict_strategy           = $conflict_strategy;
	}

	/**
	 * @param array<string, mixed> $data
	 */
	public static function fromArray( array $data ): self {
		$logging_level = self::sanitizeChoice(
			$data['logging_level'] ?? 'warning',
			self::LOGGING_LEVELS,
			'warning'
		);

		$conflict_strategy = self::sanitizeChoice(
			$data['conflict_strategy'] ?? 'last_write_wins',
			self::CONFLICT_STRATEGIES,
			'last_write_wins'
		);

		$automatic_url_replacement = array_key_exists( 'automatic_url_replacement', $data )
			? (bool) $data['automatic_url_replacement']
			: true;

		return new self(
			self::sanitizeSyncPairs( $data['sync_pairs'] ?? array() ),
			$logging_level,
			$automatic_url_replacement,
			$conflict_strategy
		);
	}

	/**
	 * @return array<int, array{name: string, source_url: string, destination_url: string}>
	 */
	public function syncPairs(): array {
		return $this->sync_pairs;
	}

	public function loggingLevel(): string {
		return $this->logging_level;
	}

	public function automaticUrlReplacementEnabled(): bool {
		return $this->automatic_url_replacement;
	}

	public function conflictStrategy(): string {
		return $this->conflict_strategy;
	}

	/**
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		return array(
			'sync_pairs'                 => $this->sync_pairs,
			'logging_level'             => $this->logging_level,
			'automatic_url_replacement' => $this->automatic_url_replacement,
			'conflict_strategy'         => $this->conflict_strategy,
		);
	}

	/**
	 * @param mixed $value
	 * @param array<int, string> $allowed
	 */
	private static function sanitizeChoice( $value, array $allowed, string $fallback ): string {
		$sanitized = sanitize_text_field( (string) $value );

		return in_array( $sanitized, $allowed, true ) ? $sanitized : $fallback;
	}

	/**
	 * @param mixed $pairs
	 * @return array<int, array{name: string, source_url: string, destination_url: string}>
	 */
	private static function sanitizeSyncPairs( $pairs ): array {
		if ( ! is_array( $pairs ) ) {
			return array();
		}

		$sanitized = array();

		foreach ( $pairs as $pair ) {
			if ( ! is_array( $pair ) ) {
				continue;
			}

			$name            = sanitize_text_field( (string) ( $pair['name'] ?? '' ) );
			$source_url      = esc_url( (string) ( $pair['source_url'] ?? '' ) );
			$destination_url = esc_url( (string) ( $pair['destination_url'] ?? '' ) );

			if ( '' === $name || '' === $source_url || '' === $destination_url ) {
				continue;
			}

			$sanitized[] = array(
				'name'            => $name,
				'source_url'      => $source_url,
				'destination_url' => $destination_url,
			);
		}

		return $sanitized;
	}
}
  • Step 4: Implement settings repository

Create src/Settings/SettingsRepository.php:

<?php

namespace WPContentSync\Settings;

final class SettingsRepository {
	public const OPTION_NAME = 'wpcs_settings';

	public function get(): Settings {
		$value = get_option( self::OPTION_NAME, array() );

		return Settings::fromArray( is_array( $value ) ? $value : array() );
	}

	public function save( Settings $settings ): void {
		update_option( self::OPTION_NAME, $settings->toArray(), false );
	}

	/**
	 * @param mixed $value
	 * @return array<string, mixed>
	 */
	public function sanitizeOption( $value ): array {
		return Settings::fromArray( is_array( $value ) ? $value : array() )->toArray();
	}
}
  • Step 5: Run tests to verify they pass

Run: composer test -- --filter SettingsTest

Expected: PASS with 4 tests.

  • Step 6: Commit
git add src/Settings/Settings.php src/Settings/SettingsRepository.php tests/Unit/SettingsTest.php
git commit -m "feat: add typed plugin settings"

Task 4: Add Option-Backed Logger

Files:

  • Create: src/Logging/LoggerInterface.php

  • Create: src/Logging/OptionLogger.php

  • Create: tests/Unit/OptionLoggerTest.php

  • Step 1: Write failing logger tests

Create tests/Unit/OptionLoggerTest.php:

<?php

namespace WPContentSync\Tests\Unit;

use PHPUnit\Framework\TestCase;
use WPContentSync\Logging\OptionLogger;

class OptionLoggerTest extends TestCase {
	protected function setUp(): void {
		$GLOBALS['wpcs_test_options'] = array();
	}

	public function test_it_records_log_entries(): void {
		$logger = new OptionLogger( 10 );

		$logger->warning( 'Connection failed.', array( 'url' => 'https://example.test' ) );

		$entries = get_option( OptionLogger::OPTION_NAME, array() );

		self::assertCount( 1, $entries );
		self::assertSame( 'warning', $entries[0]['level'] );
		self::assertSame( 'Connection failed.', $entries[0]['message'] );
		self::assertSame( 'https://example.test', $entries[0]['context']['url'] );
	}

	public function test_it_redacts_sensitive_context_values(): void {
		$logger = new OptionLogger( 10 );

		$logger->error(
			'Authentication failed.',
			array(
				'application_password' => 'secret-value',
				'token'                => 'token-value',
				'username'             => 'admin',
			)
		);

		$entries = get_option( OptionLogger::OPTION_NAME, array() );

		self::assertSame( '[redacted]', $entries[0]['context']['application_password'] );
		self::assertSame( '[redacted]', $entries[0]['context']['token'] );
		self::assertSame( 'admin', $entries[0]['context']['username'] );
	}

	public function test_it_limits_retained_entries(): void {
		$logger = new OptionLogger( 2 );

		$logger->info( 'First' );
		$logger->info( 'Second' );
		$logger->info( 'Third' );

		$entries = get_option( OptionLogger::OPTION_NAME, array() );

		self::assertCount( 2, $entries );
		self::assertSame( 'Second', $entries[0]['message'] );
		self::assertSame( 'Third', $entries[1]['message'] );
	}
}
  • Step 2: Add option stubs to test bootstrap

Modify tests/bootstrap.php by appending:

if ( ! function_exists( 'get_option' ) ) {
	function get_option( $name, $default = false ) {
		return $GLOBALS['wpcs_test_options'][ $name ] ?? $default;
	}
}

if ( ! function_exists( 'update_option' ) ) {
	function update_option( $name, $value, $autoload = null ) {
		$GLOBALS['wpcs_test_options'][ $name ] = $value;
		return true;
	}
}
  • Step 3: Run test to verify it fails

Run: composer test -- --filter OptionLoggerTest

Expected: FAIL because class WPContentSync\Logging\OptionLogger does not exist.

  • Step 4: Implement logger interface

Create src/Logging/LoggerInterface.php:

<?php

namespace WPContentSync\Logging;

interface LoggerInterface {
	/**
	 * @param array<string, mixed> $context
	 */
	public function error( string $message, array $context = array() ): void;

	/**
	 * @param array<string, mixed> $context
	 */
	public function warning( string $message, array $context = array() ): void;

	/**
	 * @param array<string, mixed> $context
	 */
	public function info( string $message, array $context = array() ): void;

	/**
	 * @param array<string, mixed> $context
	 */
	public function debug( string $message, array $context = array() ): void;
}
  • Step 5: Implement option logger

Create src/Logging/OptionLogger.php:

<?php

namespace WPContentSync\Logging;

final class OptionLogger implements LoggerInterface {
	public const OPTION_NAME = 'wpcs_logs';

	private const SENSITIVE_KEYS = array(
		'application_password',
		'password',
		'token',
		'authorization',
		'secret',
	);

	private int $max_entries;

	public function __construct( int $max_entries = 200 ) {
		$this->max_entries = max( 1, $max_entries );
	}

	public function error( string $message, array $context = array() ): void {
		$this->log( 'error', $message, $context );
	}

	public function warning( string $message, array $context = array() ): void {
		$this->log( 'warning', $message, $context );
	}

	public function info( string $message, array $context = array() ): void {
		$this->log( 'info', $message, $context );
	}

	public function debug( string $message, array $context = array() ): void {
		$this->log( 'debug', $message, $context );
	}

	/**
	 * @param array<string, mixed> $context
	 */
	private function log( string $level, string $message, array $context ): void {
		$entries = get_option( self::OPTION_NAME, array() );
		$entries = is_array( $entries ) ? $entries : array();

		$entries[] = array(
			'timestamp' => gmdate( 'c' ),
			'level'     => $level,
			'message'   => sanitize_text_field( $message ),
			'context'   => $this->redactContext( $context ),
		);

		if ( count( $entries ) > $this->max_entries ) {
			$entries = array_slice( $entries, -1 * $this->max_entries );
		}

		update_option( self::OPTION_NAME, $entries, false );
	}

	/**
	 * @param array<string, mixed> $context
	 * @return array<string, mixed>
	 */
	private function redactContext( array $context ): array {
		$redacted = array();

		foreach ( $context as $key => $value ) {
			$normalized_key = strtolower( (string) $key );
			$redacted[ $key ] = in_array( $normalized_key, self::SENSITIVE_KEYS, true )
				? '[redacted]'
				: $value;
		}

		return $redacted;
	}
}
  • Step 6: Run tests to verify they pass

Run: composer test -- --filter OptionLoggerTest

Expected: PASS with 3 tests.

  • Step 7: Commit
git add src/Logging/LoggerInterface.php src/Logging/OptionLogger.php tests/Unit/OptionLoggerTest.php tests/bootstrap.php
git commit -m "feat: add bounded option logger"

Task 5: Add Plugin Bootstrap and Lifecycle Hooks

Files:

  • Create: wp-content-sync.php

  • Create: src/Plugin.php

  • Create: src/Activator.php

  • Create: src/Deactivator.php

  • Step 1: Create the plugin entrypoint

Create wp-content-sync.php:

<?php
/**
 * Plugin Name: WP Content Sync
 * Description: Bidirectional content synchronization between WordPress instances.
 * Version: 0.1.0
 * Requires at least: 5.6
 * Requires PHP: 7.4
 * Author: WP Content Sync Contributors
 * License: GPL-2.0-or-later
 * Text Domain: wp-content-sync
 *
 * @package WPContentSync
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'WPCS_PLUGIN_FILE', __FILE__ );
define( 'WPCS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WPCS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'WPCS_VERSION', '0.1.0' );

$wpcs_autoload = __DIR__ . '/vendor/autoload.php';

if ( file_exists( $wpcs_autoload ) ) {
	require_once $wpcs_autoload;
}

register_activation_hook( __FILE__, array( \WPContentSync\Activator::class, 'activate' ) );
register_deactivation_hook( __FILE__, array( \WPContentSync\Deactivator::class, 'deactivate' ) );

add_action(
	'plugins_loaded',
	static function (): void {
		$plugin = \WPContentSync\Plugin::create();
		$plugin->register();
	}
);
  • Step 2: Implement activator

Create src/Activator.php:

<?php

namespace WPContentSync;

use WPContentSync\Settings\Settings;
use WPContentSync\Settings\SettingsRepository;

final class Activator {
	public static function activate(): void {
		if ( false === get_option( SettingsRepository::OPTION_NAME, false ) ) {
			update_option( SettingsRepository::OPTION_NAME, Settings::fromArray( array() )->toArray(), false );
		}
	}
}
  • Step 3: Implement deactivator

Create src/Deactivator.php:

<?php

namespace WPContentSync;

final class Deactivator {
	public static function deactivate(): void {
		delete_transient( 'wpcs_active_operation' );
	}
}
  • Step 4: Implement plugin lifecycle coordinator

Create src/Plugin.php:

<?php

namespace WPContentSync;

use WPContentSync\Admin\AdminPage;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Logging\OptionLogger;
use WPContentSync\Settings\SettingsRepository;

final class Plugin {
	private Container $container;

	private function __construct( Container $container ) {
		$this->container = $container;
	}

	public static function create(): self {
		$container = new Container();

		$container->factory(
			SettingsRepository::class,
			static function (): SettingsRepository {
				return new SettingsRepository();
			}
		);

		$container->factory(
			LoggerInterface::class,
			static function (): LoggerInterface {
				return new OptionLogger();
			}
		);

		$container->factory(
			AdminPage::class,
			static function () use ( $container ): AdminPage {
				return new AdminPage(
					$container->get( SettingsRepository::class ),
					$container->get( LoggerInterface::class )
				);
			}
		);

		return new self( $container );
	}

	public function register(): void {
		$this->container->get( AdminPage::class )->register();
	}
}
  • Step 5: Add missing transient stub to test bootstrap

Modify tests/bootstrap.php by appending:

if ( ! function_exists( 'delete_transient' ) ) {
	function delete_transient( $name ) {
		unset( $GLOBALS['wpcs_test_transients'][ $name ] );
		return true;
	}
}
  • Step 6: Run static checks for lifecycle files

Run: composer stan

Expected: PASS with no PHPStan errors for src and wp-content-sync.php.

  • Step 7: Commit
git add wp-content-sync.php src/Plugin.php src/Activator.php src/Deactivator.php tests/bootstrap.php
git commit -m "feat: add plugin bootstrap lifecycle"

Task 6: Add Admin Dashboard Shell

Files:

  • Create: src/Admin/AdminPage.php

  • Create: templates/admin/dashboard.php

  • Modify: src/Plugin.php

  • Modify: tests/bootstrap.php

  • Step 1: Implement admin page controller

Create src/Admin/AdminPage.php:

<?php

namespace WPContentSync\Admin;

use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Settings\SettingsRepository;

final class AdminPage {
	private SettingsRepository $settings_repository;
	private LoggerInterface $logger;

	public function __construct( SettingsRepository $settings_repository, LoggerInterface $logger ) {
		$this->settings_repository = $settings_repository;
		$this->logger              = $logger;
	}

	public function register(): void {
		add_action( 'admin_menu', array( $this, 'registerMenu' ) );
		add_action( 'admin_init', array( $this, 'registerSettings' ) );
	}

	public function registerMenu(): void {
		add_management_page(
			__( 'WP Content Sync', 'wp-content-sync' ),
			__( 'Content Sync', 'wp-content-sync' ),
			'manage_options',
			'wp-content-sync',
			array( $this, 'render' )
		);
	}

	public function registerSettings(): void {
		register_setting(
			'wpcs_settings',
			SettingsRepository::OPTION_NAME,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this->settings_repository, 'sanitizeOption' ),
				'default'           => $this->settings_repository->get()->toArray(),
			)
		);
	}

	public function render(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to access WP Content Sync.', 'wp-content-sync' ) );
		}

		$settings = $this->settings_repository->get();
		$this->logger->debug( 'Admin dashboard viewed.' );

		include WPCS_PLUGIN_DIR . 'templates/admin/dashboard.php';
	}
}
  • Step 2: Create escaped dashboard template

Create templates/admin/dashboard.php:

<?php
/**
 * Admin dashboard template.
 *
 * @package WPContentSync
 *
 * @var \WPContentSync\Settings\Settings $settings
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}
?>
<div class="wrap">
	<h1><?php echo esc_html__( 'WP Content Sync', 'wp-content-sync' ); ?></h1>
	<p><?php echo esc_html__( 'Configure sync pairs, test connections, and run push or pull operations from this screen.', 'wp-content-sync' ); ?></p>

	<div class="notice notice-info">
		<p>
			<?php
			echo esc_html__(
				'The foundation is installed. Sync pair management, URL mappings, file transport, and REST transport will be added in upcoming implementation phases.',
				'wp-content-sync'
			);
			?>
		</p>
	</div>

	<h2><?php echo esc_html__( 'Current Defaults', 'wp-content-sync' ); ?></h2>
	<table class="widefat striped">
		<tbody>
			<tr>
				<th scope="row"><?php echo esc_html__( 'Configured Sync Pairs', 'wp-content-sync' ); ?></th>
				<td><?php echo esc_html( (string) count( $settings->syncPairs() ) ); ?></td>
			</tr>
			<tr>
				<th scope="row"><?php echo esc_html__( 'Logging Level', 'wp-content-sync' ); ?></th>
				<td><?php echo esc_html( $settings->loggingLevel() ); ?></td>
			</tr>
			<tr>
				<th scope="row"><?php echo esc_html__( 'URL Replacement', 'wp-content-sync' ); ?></th>
				<td>
					<?php
					echo esc_html(
						$settings->automaticUrlReplacementEnabled()
							? __( 'Enabled', 'wp-content-sync' )
							: __( 'Disabled', 'wp-content-sync' )
					);
					?>
				</td>
			</tr>
			<tr>
				<th scope="row"><?php echo esc_html__( 'Conflict Strategy', 'wp-content-sync' ); ?></th>
				<td><?php echo esc_html( $settings->conflictStrategy() ); ?></td>
			</tr>
		</tbody>
	</table>
</div>
  • Step 3: Add admin WordPress stubs for static/unit runs

Modify tests/bootstrap.php by appending:

if ( ! function_exists( '__' ) ) {
	function __( $text, $domain = 'default' ) {
		return $text;
	}
}

if ( ! function_exists( 'esc_html__' ) ) {
	function esc_html__( $text, $domain = 'default' ) {
		return esc_html( $text );
	}
}

if ( ! function_exists( 'add_action' ) ) {
	function add_action( $hook_name, $callback ) {
		$GLOBALS['wpcs_test_actions'][ $hook_name ][] = $callback;
		return true;
	}
}

if ( ! function_exists( 'add_management_page' ) ) {
	function add_management_page( $page_title, $menu_title, $capability, $menu_slug, $callback ) {
		$GLOBALS['wpcs_test_admin_pages'][ $menu_slug ] = compact( 'page_title', 'menu_title', 'capability', 'callback' );
		return $menu_slug;
	}
}

if ( ! function_exists( 'register_setting' ) ) {
	function register_setting( $option_group, $option_name, $args = array() ) {
		$GLOBALS['wpcs_test_registered_settings'][ $option_name ] = compact( 'option_group', 'args' );
		return true;
	}
}

if ( ! function_exists( 'current_user_can' ) ) {
	function current_user_can( $capability ) {
		return 'manage_options' === $capability;
	}
}

if ( ! function_exists( 'wp_die' ) ) {
	function wp_die( $message ) {
		throw new \RuntimeException( (string) $message );
	}
}
  • Step 4: Run lint and static analysis

Run: composer lint

Expected: PASS with no PHPCS violations.

Run: composer stan

Expected: PASS with no PHPStan errors.

  • Step 5: Commit
git add src/Admin/AdminPage.php templates/admin/dashboard.php tests/bootstrap.php
git commit -m "feat: add admin dashboard shell"

Task 7: Run Full Foundation Verification

Files:

  • Verify all files created in Tasks 1-6.

  • Step 1: Run Composer validation

Run: composer validate --strict

Expected: PASS.

  • Step 2: Run PHPCS

Run: composer lint

Expected: PASS.

  • Step 3: Run PHPStan

Run: composer stan

Expected: PASS.

  • Step 4: Run PHPUnit

Run: composer test

Expected: PASS with all unit tests.

  • Step 5: Perform manual WordPress smoke test

Install the plugin directory into a WordPress 5.6+ site and activate WP Content Sync from the Plugins screen.

Expected:

  • Activation completes without fatal errors.

  • A Content Sync item appears under Tools.

  • Opening Tools > Content Sync shows the dashboard shell.

  • The dashboard shows Configured Sync Pairs as 0, Logging Level as warning, URL Replacement as Enabled, and Conflict Strategy as last_write_wins.

  • Step 6: Commit final verification note if code changed during fixes

git status --short
git add .
git commit -m "chore: verify plugin foundation"

Self-Review

Spec coverage in this foundation plan:

  • Covers initial admin interface shell, settings persistence, secure defaults, logging boundary, plugin lifecycle, and project tooling.
  • Defers sync engine, content handlers, URL transformation, REST transport, file transport, conflict resolution, and background progress to later roadmap phases.
  • Security requirements begin here through capability checks, escaped admin output, sanitized settings, and log redaction.

Placeholder scan: This plan intentionally avoids placeholder implementation steps. Deferred product areas are listed in the roadmap as later plans rather than vague steps inside this plan.

Type consistency: SettingsRepository, Settings, LoggerInterface, OptionLogger, AdminPage, Container, Activator, Deactivator, and Plugin names are consistent across tasks.