Files
muninn/docs/superpowers/plans/2026-07-19-wordpress-publish.md

34 KiB

WordPress Publishing 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: Let a Site be marked as WordPress-powered with Application Password credentials, and add php artisan posts:publish {post} to push an existing Post (type blog) to that site's WordPress REST API, updating the post's status and published_url based on the result.

Architecture: Two additive migrations (sites.cms_type/wp_username/wp_app_password, posts.published_url), one new Artisan command (app/Console/Commands/PublishPost.php) using Laravel's Http facade (Guzzle under the hood, already a framework dependency — no new package) with HTTP Basic Auth against {site.url}/wp-json/wp/v2/posts, and updates to the existing Sites Livewire component/view, SiteResource, and PostResource to surface the new fields. No new package, no new controller, no new API routes.

Tech Stack: Laravel 13, PHP 8.3+, Livewire 4.3, Pest 4 (pestphp/pest-plugin-laravel), Illuminate\Support\Facades\Http for the WP REST call (mocked in tests via Http::fake()). Laravel 13's slim skeleton auto-discovers commands placed in app/Console/Commands — no Kernel.php registration needed.

Global Constraints

  • No authentication/authorization anywhere (UI, API, or command) — local network only, per spec.
  • cms_type enum: wordpress, custom, other; default custom.
  • wp_username / wp_app_password are nullable columns; no DB-level constraint ties them to cms_type = wordpress (validated at UI/command level instead).
  • posts.published_url is nullable, generic (not WP-specific), set on any successful publish.
  • The command only ever requests WordPress status: publish (immediate live publish) — no draft/future support.
  • Command validates, in order, before touching the Post's status: post.type === 'blog', post.title present, post.site.cms_type === 'wordpress', site.wp_username + site.wp_app_password both present. Any failure here exits 1 with a descriptive message and leaves Post.status untouched.
  • Only after pre-flight passes and the HTTP call is actually attempted does the command write status = 'published' (+ published_url) or status = 'failed' to the Post.
  • Sites API (GET /api/sites) returns wp_app_password in plain text — consistent with the existing precedent that Social Accounts' API returns plain-text passwords, since the AI agent needs credentials directly.
  • WordPress auth is Application Passwords (HTTP Basic Auth with wp_username + wp_app_password) — no plugin-based schemes (JWT, API-key plugins).

File Structure

  • database/migrations/xxxx_add_cms_fields_to_sites_table.php — new
  • database/migrations/xxxx_add_published_url_to_posts_table.php — new
  • app/Models/Site.php — modify (add CMS_TYPES const, fillable fields)
  • app/Models/Post.php — modify (add published_url to fillable)
  • database/factories/SiteFactory.php — modify (default cms_type = custom, nullable WP fields)
  • app/Console/Commands/PublishPost.php — new (the posts:publish command)
  • app/Http/Resources/SiteResource.php — modify (add cms_type, wp_username, wp_app_password)
  • app/Http/Resources/PostResource.php — modify (add published_url)
  • app/Livewire/Sites/Index.php — modify (cms_type + WP credential fields, validation, reveal toggle)
  • resources/views/livewire/sites/index.blade.php — modify (CMS Type column/dropdown, conditional WP fields, password reveal)
  • tests/Feature/PublishPostCommandTest.php — new
  • tests/Feature/Api/SitesApiTest.php — modify (assert new fields present)
  • tests/Feature/Api/PostsApiTest.php — modify (assert published_url present)
  • tests/Feature/Livewire/SitesTest.php — modify (cms_type/WP field coverage)
  • tests/Feature/SiteModelTest.php — modify (default cms_type coverage)

Task 1: Add cms_type + WordPress credential columns to sites

Files:

  • Create: database/migrations/xxxx_add_cms_fields_to_sites_table.php
  • Modify: app/Models/Site.php
  • Modify: database/factories/SiteFactory.php
  • Test: tests/Feature/SiteModelTest.php

Interfaces:

  • Produces: Site::CMS_TYPES = ['wordpress', 'custom', 'other'] constant; Site fillable gains cms_type, wp_username, wp_app_password; DB columns sites.cms_type (string, default 'custom'), sites.wp_username (string, nullable), sites.wp_app_password (string, nullable).

  • Step 1: Write the failing test

Add to tests/Feature/SiteModelTest.php:

test('a site defaults to cms_type custom with null WordPress credentials', function () {
    $site = Site::create([
        'name' => 'My Blog',
        'url' => 'https://example.com',
    ]);

    expect($site->fresh())
        ->cms_type->toBe('custom')
        ->wp_username->toBeNull()
        ->wp_app_password->toBeNull();
});

test('a site can be created as WordPress with credentials', function () {
    $site = Site::create([
        'name' => 'My WP Blog',
        'url' => 'https://wp.example.com',
        'cms_type' => 'wordpress',
        'wp_username' => 'admin',
        'wp_app_password' => 'abcd 1234 efgh 5678',
    ]);

    expect($site->fresh())
        ->cms_type->toBe('wordpress')
        ->wp_username->toBe('admin')
        ->wp_app_password->toBe('abcd 1234 efgh 5678');
});
  • Step 2: Run test to verify it fails

Run: php artisan test --filter=SiteModelTest Expected: FAIL — cms_type column/attribute does not exist (SQL error or null mismatch).

  • Step 3: Create the migration

Run: php artisan make:migration add_cms_fields_to_sites_table --table=sites

Replace the generated file's contents with:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('sites', function (Blueprint $table) {
            $table->string('cms_type')->default('custom')->after('description');
            $table->string('wp_username')->nullable()->after('cms_type');
            $table->string('wp_app_password')->nullable()->after('wp_username');
        });
    }

    public function down(): void
    {
        Schema::table('sites', function (Blueprint $table) {
            $table->dropColumn(['cms_type', 'wp_username', 'wp_app_password']);
        });
    }
};
  • Step 4: Update the Site model

Replace app/Models/Site.php contents with:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Site extends Model
{
    use HasFactory;

    public const CMS_TYPES = ['wordpress', 'custom', 'other'];

    protected $fillable = [
        'name',
        'url',
        'description',
        'cms_type',
        'wp_username',
        'wp_app_password',
    ];
}
  • Step 5: Update SiteFactory

Replace database/factories/SiteFactory.php contents with:

<?php

namespace Database\Factories;

use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory;

class SiteFactory extends Factory
{
    protected $model = Site::class;

    public function definition(): array
    {
        return [
            'name' => fake()->company(),
            'url' => fake()->url(),
            'description' => fake()->optional()->sentence(),
            'cms_type' => 'custom',
            'wp_username' => null,
            'wp_app_password' => null,
        ];
    }

    public function wordpress(): static
    {
        return $this->state(fn () => [
            'cms_type' => 'wordpress',
            'wp_username' => fake()->userName(),
            'wp_app_password' => fake()->password(),
        ]);
    }
}
  • Step 6: Run the migration and tests

Run: php artisan migrate && php artisan test --filter=SiteModelTest Expected: PASS (both new tests, plus the two pre-existing ones).

  • Step 7: Commit
git add database/migrations app/Models/Site.php database/factories/SiteFactory.php tests/Feature/SiteModelTest.php
git commit -m "feat: add cms_type and WordPress credential fields to sites"

Task 2: Add published_url column to posts

Files:

  • Create: database/migrations/xxxx_add_published_url_to_posts_table.php
  • Modify: app/Models/Post.php
  • Test: tests/Feature/PostModelTest.php

Interfaces:

  • Produces: Post fillable gains published_url; DB column posts.published_url (string, nullable).

  • Step 1: Write the failing test

Add to tests/Feature/PostModelTest.php:

test('a post can store a published_url', function () {
    $post = Post::factory()->create(['published_url' => null]);

    $post->update(['published_url' => 'https://example.com/2026/07/my-post']);

    expect($post->fresh()->published_url)->toBe('https://example.com/2026/07/my-post');
});

test('published_url defaults to null', function () {
    $post = Post::factory()->create();

    expect($post->fresh()->published_url)->toBeNull();
});
  • Step 2: Run test to verify it fails

Run: php artisan test --filter=PostModelTest Expected: FAIL — published_url column does not exist.

  • Step 3: Create the migration

Run: php artisan make:migration add_published_url_to_posts_table --table=posts

Replace the generated file's contents with:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->string('published_url')->nullable()->after('scheduled_at');
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropColumn('published_url');
        });
    }
};
  • Step 4: Update the Post model

In app/Models/Post.php, add 'published_url' to the $fillable array (after 'scheduled_at'):

    protected $fillable = [
        'site_id',
        'social_account_id',
        'type',
        'title',
        'body',
        'status',
        'scheduled_at',
        'published_url',
    ];
  • Step 5: Run the migration and tests

Run: php artisan migrate && php artisan test --filter=PostModelTest Expected: PASS.

  • Step 6: Commit
git add database/migrations app/Models/Post.php tests/Feature/PostModelTest.php
git commit -m "feat: add published_url column to posts"

Task 3: Expose new fields in SiteResource and PostResource

Files:

  • Modify: app/Http/Resources/SiteResource.php
  • Modify: app/Http/Resources/PostResource.php
  • Modify: tests/Feature/Api/SitesApiTest.php
  • Modify: tests/Feature/Api/PostsApiTest.php

Interfaces:

  • Consumes: Site::CMS_TYPES (Task 1), Post.published_url (Task 2).

  • Produces: GET /api/sites and GET /api/sites/{id} responses include cms_type, wp_username, wp_app_password. GET /api/posts and GET /api/posts/{id} responses include published_url.

  • Step 1: Write the failing tests

Add to tests/Feature/Api/SitesApiTest.php:

test('it includes WordPress fields in the site response', function () {
    $site = Site::factory()->wordpress()->create([
        'wp_username' => 'admin',
        'wp_app_password' => 'abcd 1234 efgh 5678',
    ]);

    $response = $this->getJson("/api/sites/{$site->id}");

    $response->assertOk()
        ->assertJsonPath('data.cms_type', 'wordpress')
        ->assertJsonPath('data.wp_username', 'admin')
        ->assertJsonPath('data.wp_app_password', 'abcd 1234 efgh 5678');
});

Add to tests/Feature/Api/PostsApiTest.php:

test('it includes published_url in the post response', function () {
    $post = Post::factory()->create(['published_url' => 'https://example.com/my-post']);

    $response = $this->getJson("/api/posts/{$post->id}");

    $response->assertOk()->assertJsonPath('data.published_url', 'https://example.com/my-post');
});
  • Step 2: Run tests to verify they fail

Run: php artisan test --filter=SitesApiTest && php artisan test --filter=PostsApiTest Expected: FAIL — cms_type/wp_username/wp_app_password/published_url keys missing from JSON response.

  • Step 3: Update SiteResource

Replace app/Http/Resources/SiteResource.php contents with:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SiteResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'url' => $this->url,
            'description' => $this->description,
            'cms_type' => $this->cms_type,
            'wp_username' => $this->wp_username,
            'wp_app_password' => $this->wp_app_password,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}
  • Step 4: Update PostResource

In app/Http/Resources/PostResource.php, add 'published_url' => $this->published_url, after the 'scheduled_at' line:

    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'site_id' => $this->site_id,
            'social_account_id' => $this->social_account_id,
            'type' => $this->type,
            'title' => $this->title,
            'body' => $this->body,
            'status' => $this->status,
            'scheduled_at' => $this->scheduled_at,
            'published_url' => $this->published_url,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
  • Step 5: Run tests to verify they pass

Run: php artisan test --filter=SitesApiTest && php artisan test --filter=PostsApiTest Expected: PASS.

  • Step 6: Commit
git add app/Http/Resources/SiteResource.php app/Http/Resources/PostResource.php tests/Feature/Api/SitesApiTest.php tests/Feature/Api/PostsApiTest.php
git commit -m "feat: expose cms_type/WordPress credentials and published_url in API responses"

Task 4: posts:publish Artisan command

Files:

  • Create: app/Console/Commands/PublishPost.php
  • Create: tests/Feature/PublishPostCommandTest.php

Interfaces:

  • Consumes: Site::CMS_TYPES (Task 1), site.wp_username/site.wp_app_password (Task 1), Post::TYPES including 'blog' (existing), Post.published_url (Task 2).

  • Produces: php artisan posts:publish {post} — signature posts:publish {post}. On success prints {"success": true, "url": "<published_url>"} to stdout and exits 0, having set Post.status = 'published' and Post.published_url. On failure prints {"success": false, "error": "<message>"} and exits 1.

  • Step 1: Write the failing tests

Create tests/Feature/PublishPostCommandTest.php:

<?php

use App\Models\Post;
use App\Models\Site;
use Illuminate\Support\Facades\Http;

test('it fails when the post is not found', function () {
    $this->artisan('posts:publish', ['post' => 999])
        ->assertExitCode(1);
});

test('it fails when the post type is not blog', function () {
    $site = Site::factory()->wordpress()->create();
    $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'twitter', 'title' => 'Hi']);

    $this->artisan('posts:publish', ['post' => $post->id])
        ->expectsOutputToContain("expected 'blog'")
        ->assertExitCode(1);

    expect($post->fresh()->status)->toBe($post->status);
});

test('it fails when the post has no title', function () {
    $site = Site::factory()->wordpress()->create();
    $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => null]);

    $this->artisan('posts:publish', ['post' => $post->id])
        ->expectsOutputToContain('no title')
        ->assertExitCode(1);
});

test('it fails when the site is not configured as WordPress', function () {
    $site = Site::factory()->create(['cms_type' => 'custom']);
    $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);

    $this->artisan('posts:publish', ['post' => $post->id])
        ->expectsOutputToContain('not configured as WordPress')
        ->assertExitCode(1);
});

test('it fails when the WordPress site is missing credentials', function () {
    $site = Site::factory()->create(['cms_type' => 'wordpress', 'wp_username' => null, 'wp_app_password' => null]);
    $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);

    $this->artisan('posts:publish', ['post' => $post->id])
        ->expectsOutputToContain('missing WordPress credentials')
        ->assertExitCode(1);
});

test('it publishes successfully and updates the post', function () {
    Http::fake([
        '*/wp-json/wp/v2/posts' => Http::response([
            'id' => 42,
            'link' => 'https://wp.example.com/2026/07/hello-world/',
        ], 201),
    ]);

    $site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com']);
    $post = Post::factory()->create([
        'site_id' => $site->id,
        'type' => 'blog',
        'title' => 'Hello World',
        'body' => 'Post content',
        'status' => 'scheduled',
    ]);

    $this->artisan('posts:publish', ['post' => $post->id])
        ->expectsOutputToContain('"success":true')
        ->assertExitCode(0);

    expect($post->fresh())
        ->status->toBe('published')
        ->published_url->toBe('https://wp.example.com/2026/07/hello-world/');

    Http::assertSent(function ($request) use ($site) {
        return $request->url() === "{$site->url}/wp-json/wp/v2/posts"
            && $request['title'] === 'Hello World'
            && $request['content'] === 'Post content'
            && $request['status'] === 'publish';
    });
});

test('it marks the post as failed when WordPress returns an error', function () {
    Http::fake([
        '*/wp-json/wp/v2/posts' => Http::response([
            'code' => 'rest_cannot_create',
            'message' => 'Sorry, you are not allowed to create posts.',
        ], 401),
    ]);

    $site = Site::factory()->wordpress()->create();
    $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);

    $this->artisan('posts:publish', ['post' => $post->id])
        ->expectsOutputToContain('not allowed to create posts')
        ->assertExitCode(1);

    expect($post->fresh()->status)->toBe('failed');
});

test('it marks the post as failed when the site is unreachable', function () {
    Http::fake(function () {
        throw new \Illuminate\Http\Client\ConnectionException('Could not connect');
    });

    $site = Site::factory()->wordpress()->create();
    $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'status' => 'draft']);

    $this->artisan('posts:publish', ['post' => $post->id])
        ->expectsOutputToContain('could not connect')
        ->assertExitCode(1);

    expect($post->fresh()->status)->toBe('failed');
});
  • Step 2: Run tests to verify they fail

Run: php artisan test --filter=PublishPostCommandTest Expected: FAIL — command posts:publish does not exist.

  • Step 3: Create the command

Create app/Console/Commands/PublishPost.php:

<?php

namespace App\Console\Commands;

use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

class PublishPost extends Command
{
    protected $signature = 'posts:publish {post}';

    protected $description = 'Publish an existing blog post to its site\'s WordPress REST API';

    public function handle(): int
    {
        $post = Post::with('site')->find($this->argument('post'));

        if (! $post) {
            return $this->fail("Post {$this->argument('post')} not found.");
        }

        if ($post->type !== 'blog') {
            return $this->fail("Post {$post->id} has type '{$post->type}', expected 'blog' to publish to a CMS.");
        }

        if (! $post->title) {
            return $this->fail("Post {$post->id} has no title, required to publish to WordPress.");
        }

        $site = $post->site;

        if ($site->cms_type !== 'wordpress') {
            return $this->fail("Site '{$site->name}' is not configured as WordPress (cms_type={$site->cms_type}).");
        }

        if (! $site->wp_username || ! $site->wp_app_password) {
            return $this->fail("Site '{$site->name}' is missing WordPress credentials.");
        }

        try {
            $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
                ->post(rtrim($site->url, '/') . '/wp-json/wp/v2/posts', [
                    'title' => $post->title,
                    'content' => $post->body,
                    'status' => 'publish',
                ]);
        } catch (ConnectionException $e) {
            return $this->publishFailed($post, "could not connect to site: {$e->getMessage()}");
        }

        if ($response->failed()) {
            $message = $response->json('message') ?? 'unexpected response from WordPress';

            return $this->publishFailed($post, $message);
        }

        $link = $response->json('link');

        if (! $link) {
            return $this->publishFailed($post, 'unexpected response from WordPress: missing post link');
        }

        $post->update(['status' => 'published', 'published_url' => $link]);

        $this->line(json_encode(['success' => true, 'url' => $link]));

        return self::SUCCESS;
    }

    private function fail(string $message): int
    {
        $this->line(json_encode(['success' => false, 'error' => $message]));

        return self::FAILURE;
    }

    private function publishFailed(Post $post, string $message): int
    {
        $post->update(['status' => 'failed']);

        return $this->fail($message);
    }
}
  • Step 4: Run tests to verify they pass

Run: php artisan test --filter=PublishPostCommandTest Expected: PASS (all 8 tests).

  • Step 5: Commit
git add app/Console/Commands/PublishPost.php tests/Feature/PublishPostCommandTest.php
git commit -m "feat: add posts:publish command for WordPress REST API publishing"

Task 5: Sites UI — cms_type and WordPress credential fields

Files:

  • Modify: app/Livewire/Sites/Index.php
  • Modify: resources/views/livewire/sites/index.blade.php
  • Modify: tests/Feature/Livewire/SitesTest.php

Interfaces:

  • Consumes: Site::CMS_TYPES (Task 1).

  • Produces: Livewire Index component public properties cmsType, wpUsername, wpAppPassword, revealedId; method toggleReveal(int $id).

  • Step 1: Write the failing tests

Add to tests/Feature/Livewire/SitesTest.php:

test('a new WordPress site can be created with credentials', function () {
    Livewire::test(Index::class)
        ->set('name', 'WP Site')
        ->set('url', 'https://wp.example.com')
        ->set('cmsType', 'wordpress')
        ->set('wpUsername', 'admin')
        ->set('wpAppPassword', 'abcd 1234 efgh 5678')
        ->call('save');

    $site = Site::where('name', 'WP Site')->first();

    expect($site)
        ->cms_type->toBe('wordpress')
        ->wp_username->toBe('admin')
        ->wp_app_password->toBe('abcd 1234 efgh 5678');
});

test('WordPress username and app password are required together when cms_type is wordpress', function () {
    Livewire::test(Index::class)
        ->set('name', 'WP Site')
        ->set('url', 'https://wp.example.com')
        ->set('cmsType', 'wordpress')
        ->set('wpUsername', '')
        ->set('wpAppPassword', '')
        ->call('save')
        ->assertHasErrors(['wpUsername', 'wpAppPassword']);
});

test('a site defaults to custom cms_type with no WordPress fields required', function () {
    Livewire::test(Index::class)
        ->set('name', 'Plain Site')
        ->set('url', 'https://plain.example.com')
        ->call('save')
        ->assertHasNoErrors();

    expect(Site::where('name', 'Plain Site')->first()->cms_type)->toBe('custom');
});

test('editing a site loads its WordPress fields', function () {
    $site = Site::factory()->wordpress()->create(['wp_username' => 'editor']);

    Livewire::test(Index::class)
        ->call('edit', $site->id)
        ->assertSet('cmsType', 'wordpress')
        ->assertSet('wpUsername', 'editor');
});

test('WordPress app password can be revealed and hidden in the table', function () {
    $site = Site::factory()->wordpress()->create();

    Livewire::test(Index::class)
        ->call('toggleReveal', $site->id)
        ->assertSet('revealedId', $site->id)
        ->call('toggleReveal', $site->id)
        ->assertSet('revealedId', null);
});
  • Step 2: Run tests to verify they fail

Run: php artisan test --filter=SitesTest Expected: FAIL — cmsType/wpUsername/wpAppPassword/toggleReveal do not exist on the component.

  • Step 3: Update the Livewire component

Replace app/Livewire/Sites/Index.php contents with:

<?php

namespace App\Livewire\Sites;

use App\Models\Site;
use Livewire\Attributes\Layout;
use Livewire\Component;

#[Layout('components.layouts.app')]
class Index extends Component
{
    public bool $showForm = false;

    public ?int $editingId = null;

    public ?int $revealedId = null;

    public string $name = '';

    public string $url = '';

    public ?string $description = null;

    public string $cmsType = 'custom';

    public string $wpUsername = '';

    public string $wpAppPassword = '';

    public function render()
    {
        return view('livewire.sites.index', [
            'sites' => Site::orderBy('name')->get(),
            'cmsTypes' => Site::CMS_TYPES,
        ]);
    }

    public function create(): void
    {
        $this->resetForm();
        $this->showForm = true;
    }

    public function edit(int $id): void
    {
        $site = Site::findOrFail($id);
        $this->editingId = $site->id;
        $this->name = $site->name;
        $this->url = $site->url;
        $this->description = $site->description;
        $this->cmsType = $site->cms_type;
        $this->wpUsername = $site->wp_username ?? '';
        $this->wpAppPassword = $site->wp_app_password ?? '';
        $this->showForm = true;
    }

    public function save(): void
    {
        $validated = $this->validate([
            'name' => ['required', 'string', 'max:255'],
            'url' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'cmsType' => ['required', 'string', 'in:' . implode(',', Site::CMS_TYPES)],
            'wpUsername' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
            'wpAppPassword' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
        ]);

        Site::updateOrCreate(['id' => $this->editingId], [
            'name' => $validated['name'],
            'url' => $validated['url'],
            'description' => $validated['description'],
            'cms_type' => $validated['cmsType'],
            'wp_username' => $validated['cmsType'] === 'wordpress' ? $validated['wpUsername'] : null,
            'wp_app_password' => $validated['cmsType'] === 'wordpress' ? $validated['wpAppPassword'] : null,
        ]);

        $this->resetForm();
        $this->showForm = false;
    }

    public function delete(int $id): void
    {
        Site::findOrFail($id)->delete();
    }

    public function toggleReveal(int $id): void
    {
        $this->revealedId = $this->revealedId === $id ? null : $id;
    }

    public function resetForm(): void
    {
        $this->editingId = null;
        $this->name = '';
        $this->url = '';
        $this->description = null;
        $this->cmsType = 'custom';
        $this->wpUsername = '';
        $this->wpAppPassword = '';
        $this->resetValidation();
    }
}
  • Step 4: Update the view

Replace resources/views/livewire/sites/index.blade.php contents with:

<div>
    <div class="flex justify-between items-center mb-4">
        <h1 class="text-xl font-semibold">Sites</h1>
        <button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Site</button>
    </div>

    <table class="w-full bg-white border border-gray-200 rounded">
        <thead>
            <tr class="text-left border-b border-gray-200">
                <th class="p-3">Name</th>
                <th class="p-3">URL</th>
                <th class="p-3">Description</th>
                <th class="p-3">CMS Type</th>
                <th class="p-3"></th>
            </tr>
        </thead>
        <tbody>
            @foreach ($sites as $site)
                <tr wire:key="site-{{ $site->id }}" class="border-b border-gray-100">
                    <td class="p-3">{{ $site->name }}</td>
                    <td class="p-3"><a href="{{ $site->url }}" target="_blank" class="text-blue-600">{{ $site->url }}</a></td>
                    <td class="p-3">{{ $site->description }}</td>
                    <td class="p-3">
                        {{ ucfirst($site->cms_type) }}
                        @if ($site->cms_type === 'wordpress')
                            <div class="text-xs text-gray-500">
                                {{ $site->wp_username }} /
                                @if ($revealedId === $site->id)
                                    {{ $site->wp_app_password }}
                                @else
                                    ••••••••
                                @endif
                                <button wire:click="toggleReveal({{ $site->id }})" class="text-xs text-blue-600 ml-1">
                                    {{ $revealedId === $site->id ? 'Hide' : 'Show' }}
                                </button>
                            </div>
                        @endif
                    </td>
                    <td class="p-3 text-right space-x-2">
                        <button wire:click="edit({{ $site->id }})" class="text-blue-600">Edit</button>
                        <button wire:click="delete({{ $site->id }})" wire:confirm="Delete this site?" class="text-red-600">Delete</button>
                    </td>
                </tr>
            @endforeach
        </tbody>
    </table>

    @if ($showForm)
        <div class="fixed inset-0 bg-black/30 flex items-center justify-center">
            <div class="bg-white p-6 rounded w-full max-w-md space-y-4">
                <h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Site' : 'Add Site' }}</h2>

                <div>
                    <label class="block text-sm font-medium">Name</label>
                    <input type="text" wire:model="name" class="w-full border rounded p-2">
                    @error('name') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                </div>

                <div>
                    <label class="block text-sm font-medium">URL</label>
                    <input type="text" wire:model="url" class="w-full border rounded p-2">
                    @error('url') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                </div>

                <div>
                    <label class="block text-sm font-medium">Description</label>
                    <textarea wire:model="description" class="w-full border rounded p-2"></textarea>
                </div>

                <div>
                    <label class="block text-sm font-medium">CMS Type</label>
                    <select wire:model.live="cmsType" class="w-full border rounded p-2">
                        @foreach ($cmsTypes as $type)
                            <option value="{{ $type }}">{{ ucfirst($type) }}</option>
                        @endforeach
                    </select>
                    @error('cmsType') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                </div>

                @if ($cmsType === 'wordpress')
                    <div>
                        <label class="block text-sm font-medium">WP Username</label>
                        <input type="text" wire:model="wpUsername" class="w-full border rounded p-2">
                        @error('wpUsername') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                    </div>

                    <div>
                        <label class="block text-sm font-medium">WP Application Password</label>
                        <input type="text" wire:model="wpAppPassword" class="w-full border rounded p-2">
                        @error('wpAppPassword') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                    </div>
                @endif

                <div class="flex justify-end gap-2">
                    <button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
                    <button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
                </div>
            </div>
        </div>
    @endif
</div>
  • Step 5: Run tests to verify they pass

Run: php artisan test --filter=SitesTest Expected: PASS (all tests, existing + new).

  • Step 6: Commit
git add app/Livewire/Sites/Index.php resources/views/livewire/sites/index.blade.php tests/Feature/Livewire/SitesTest.php
git commit -m "feat: manage cms_type and WordPress credentials in Sites UI"

Task 6: Full suite run

Files: none (verification only)

  • Step 1: Run the entire test suite

Run: php artisan test Expected: All tests pass, no regressions in Sites/Posts/SocialAccounts model, API, or Livewire tests.

  • Step 2: Manual smoke check of the command against real WordPress (optional, if a test WP site is available)

Run:

php artisan tinker --execute="
\$site = App\Models\Site::create(['name' => 'Test WP', 'url' => 'https://your-test-site.example', 'cms_type' => 'wordpress', 'wp_username' => 'your-user', 'wp_app_password' => 'your-app-password']);
\$post = App\Models\Post::create(['site_id' => \$site->id, 'type' => 'blog', 'title' => 'Test Post', 'body' => 'Hello from posts:publish', 'status' => 'draft']);
echo \$post->id;
"
php artisan posts:publish <post_id_from_above>

Expected: JSON {"success":true,"url":"..."} printed, exit code 0, and the post visible live on the test WordPress site.

This step is optional/manual since it requires a real WordPress instance with Application Passwords enabled — skip if none is available; the automated Http::fake() tests in Task 4 already cover the logic.