Files

38 KiB

Scheduled Posts 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: Add a Post resource — a content calendar linking a required Site, an optional SocialAccount, a type enum, title/body content, a lifecycle status, and an optional scheduled_at — with a Livewire CRUD UI and a full read/write JSON API, so an AI agent can draft, schedule, and report on posts it publishes externally.

Architecture: Follows the exact pattern established by the existing Sites/SocialAccounts feature in this codebase: one Eloquent model (Post) with two belongsTo relationships, one migration, one factory, a Livewire full-page CRUD component (index + inline modal form), and a REST API controller + API Resource. The only structural difference from the prior feature is that the Posts API is full CRUD (apiResource) rather than read-only, since the AI drives the post lifecycle end-to-end.

Tech Stack: Laravel 13, PHP 8.4, Livewire 4.3.3, Pest 4 (pestphp/pest-plugin-laravel), MySQL, Tailwind 4. Same stack as the existing feature — no new dependencies.

Global Constraints

  • No authentication/authorization anywhere (UI or API) — local network only, per spec.
  • Posts API is full CRUD (index, show, store, update, destroy) — this is the one resource in the app that is writable via API, unlike the read-only Sites/Accounts API.
  • type enum values: twitter, instagram, facebook, reddit, tiktok, linkedin, youtube, blog, other (same network vocabulary as SocialAccount::NETWORKS, plus blog).
  • status enum values: draft, scheduled, published, failed; defaults to draft.
  • site_id is required; deleting a Site cascades and deletes its posts (cascadeOnDelete()).
  • social_account_id is nullable; deleting a SocialAccount nulls out the post's link, post survives (nullOnDelete()).
  • No validation ties type to the linked account's network — these are independent fields, by design.
  • This app does not publish anything itself — no queue job or scheduler calling external platforms. The ?due=1 API filter exists so the AI can poll for what needs publishing; the AI does the actual publishing and reports back via PATCH.

File Structure

  • database/migrations/xxxx_create_posts_table.php — new
  • app/Models/Post.php — new
  • database/factories/PostFactory.php — new
  • app/Livewire/Posts/Index.php + resources/views/livewire/posts/index.blade.php — new (list + create/edit/delete UI)
  • resources/views/components/layouts/app.blade.php — modify (add "Posts" nav link)
  • routes/web.php — modify (add Posts Livewire page route)
  • app/Http/Controllers/Api/PostController.php — new (full CRUD, unlike the existing read-only API controllers)
  • app/Http/Resources/PostResource.php — new
  • app/Http/Requests/StorePostRequest.php — new (validation rules shared conceptually between store/update, kept as two small Form Request classes per Laravel convention)
  • app/Http/Requests/UpdatePostRequest.php — new
  • routes/api.php — modify (add Posts API routes)
  • tests/Feature/Livewire/PostsTest.php — new
  • tests/Feature/Api/PostsApiTest.php — new
  • tests/Feature/PostModelTest.php — new

Task 1: Post model, migration, and factory

Files:

  • Create: database/migrations/xxxx_create_posts_table.php (via php artisan make:migration)
  • Create: app/Models/Post.php
  • Create: database/factories/PostFactory.php
  • Test: tests/Feature/PostModelTest.php

Interfaces:

  • Produces: Post model with fillable site_id, social_account_id, type, title, body, status, scheduled_at; public constants Post::TYPES and Post::STATUSES; relationship methods site(): BelongsTo and socialAccount(): BelongsTo; PostFactory producing valid random posts (with a Site and, optionally, a SocialAccount created via factory relationships).

  • Step 1: Generate the migration

Run: docker compose exec laravel.test php artisan make:migration create_posts_table

(This project runs inside Docker via Laravel Sail. All php artisan/composer/npm commands in this plan must be run as docker compose exec laravel.test <command> from the project root /home/liquidsky/Projects/muninn, not as bare host commands.)

  • Step 2: Write the migration

Edit the generated file in database/migrations/:

<?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::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('site_id')->constrained()->cascadeOnDelete();
            $table->foreignId('social_account_id')->nullable()->constrained()->nullOnDelete();
            $table->enum('type', [
                'twitter', 'instagram', 'facebook', 'reddit',
                'tiktok', 'linkedin', 'youtube', 'blog', 'other',
            ]);
            $table->string('title')->nullable();
            $table->text('body');
            $table->enum('status', ['draft', 'scheduled', 'published', 'failed'])->default('draft');
            $table->timestamp('scheduled_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};
  • Step 3: Write the failing model test

Create tests/Feature/PostModelTest.php:

<?php

use App\Models\Post;
use App\Models\Site;
use App\Models\SocialAccount;

test('a post can be created with required fields only', function () {
    $site = Site::factory()->create();

    $post = Post::create([
        'site_id' => $site->id,
        'type' => 'blog',
        'body' => 'Hello world',
    ]);

    $fresh = $post->fresh();

    expect($fresh)
        ->site_id->toBe($site->id)
        ->type->toBe('blog')
        ->body->toBe('Hello world')
        ->status->toBe('draft')
        ->social_account_id->toBeNull()
        ->title->toBeNull()
        ->scheduled_at->toBeNull();
});

test('a post can be created with an account, title, status, and schedule', function () {
    $site = Site::factory()->create();
    $account = SocialAccount::factory()->create();

    $post = Post::create([
        'site_id' => $site->id,
        'social_account_id' => $account->id,
        'type' => 'twitter',
        'title' => 'Launch day',
        'body' => 'We just launched!',
        'status' => 'scheduled',
        'scheduled_at' => '2026-08-01 09:00:00',
    ]);

    $fresh = $post->fresh();

    expect($fresh)
        ->social_account_id->toBe($account->id)
        ->title->toBe('Launch day')
        ->status->toBe('scheduled')
        ->scheduled_at->not->toBeNull();
});

test('a post belongs to a site', function () {
    $site = Site::factory()->create();
    $post = Post::factory()->create(['site_id' => $site->id]);

    expect($post->site)->toBeInstanceOf(Site::class);
    expect($post->site->id)->toBe($site->id);
});

test('a post can belong to a social account', function () {
    $account = SocialAccount::factory()->create();
    $post = Post::factory()->create(['social_account_id' => $account->id]);

    expect($post->socialAccount)->toBeInstanceOf(SocialAccount::class);
    expect($post->socialAccount->id)->toBe($account->id);
});

test('deleting a site cascades and deletes its posts', function () {
    $site = Site::factory()->create();
    $post = Post::factory()->create(['site_id' => $site->id]);

    $site->delete();

    expect(Post::find($post->id))->toBeNull();
});

test('deleting a social account nulls out the post link but keeps the post', function () {
    $account = SocialAccount::factory()->create();
    $post = Post::factory()->create(['social_account_id' => $account->id]);

    $account->delete();

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

Run: docker compose exec laravel.test php artisan test tests/Feature/PostModelTest.php Expected: FAIL — Post class not found, or posts table doesn't exist.

  • Step 5: Create the Post model

Create app/Models/Post.php:

<?php

namespace App\Models;

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

class Post extends Model
{
    use HasFactory;

    public const TYPES = [
        'twitter', 'instagram', 'facebook', 'reddit',
        'tiktok', 'linkedin', 'youtube', 'blog', 'other',
    ];

    public const STATUSES = ['draft', 'scheduled', 'published', 'failed'];

    protected $fillable = [
        'site_id',
        'social_account_id',
        'type',
        'title',
        'body',
        'status',
        'scheduled_at',
    ];

    protected $casts = [
        'scheduled_at' => 'datetime',
    ];

    public function site(): BelongsTo
    {
        return $this->belongsTo(Site::class);
    }

    public function socialAccount(): BelongsTo
    {
        return $this->belongsTo(SocialAccount::class);
    }
}
  • Step 6: Create the factory

Create database/factories/PostFactory.php:

<?php

namespace Database\Factories;

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

class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition(): array
    {
        return [
            'site_id' => Site::factory(),
            'social_account_id' => null,
            'type' => fake()->randomElement(Post::TYPES),
            'title' => fake()->optional()->sentence(),
            'body' => fake()->paragraph(),
            'status' => fake()->randomElement(Post::STATUSES),
            'scheduled_at' => fake()->optional()->dateTimeBetween('now', '+1 month'),
        ];
    }
}
  • Step 7: Run migrations and test to verify it passes

Run: docker compose exec laravel.test php artisan migrate Run: docker compose exec laravel.test php artisan test tests/Feature/PostModelTest.php Expected: PASS (6 tests)

  • Step 8: Commit
git add database/migrations database/factories/PostFactory.php app/Models/Post.php tests/Feature/PostModelTest.php
git commit -m "feat: add Post model, migration, and factory"

Task 2: Posts Livewire CRUD

Files:

  • Create: app/Livewire/Posts/Index.php
  • Create: resources/views/livewire/posts/index.blade.php
  • Modify: resources/views/components/layouts/app.blade.php
  • Modify: routes/web.php
  • Test: tests/Feature/Livewire/PostsTest.php

Interfaces:

  • Consumes: App\Models\Post (Task 1, incl. TYPES/STATUSES constants), App\Models\Site, App\Models\SocialAccount, components.layouts.app layout.

  • Produces: route named posts.index at /posts; Livewire component App\Livewire\Posts\Index with public properties $siteId, $socialAccountId, $type, $title, $body, $status, $scheduledAt, $editingId, $showForm, and methods create(), edit(int $id), save(), delete(int $id), resetForm().

  • Step 1: Write the failing feature test

Create tests/Feature/Livewire/PostsTest.php:

<?php

use App\Livewire\Posts\Index;
use App\Models\Post;
use App\Models\Site;
use App\Models\SocialAccount;
use Livewire\Livewire;

test('posts index page lists existing posts', function () {
    $site = Site::factory()->create(['name' => 'My Blog']);
    Post::factory()->create(['site_id' => $site->id, 'title' => 'Hello World Post']);

    $this->get(route('posts.index'))
        ->assertOk()
        ->assertSee('Hello World Post')
        ->assertSee('My Blog');
});

test('a new post can be created with required fields only', function () {
    $site = Site::factory()->create();

    Livewire::test(Index::class)
        ->set('siteId', $site->id)
        ->set('type', 'blog')
        ->set('body', 'New post body')
        ->call('save');

    $post = Post::where('body', 'New post body')->first();
    expect($post)->not->toBeNull();
    expect($post->site_id)->toBe($site->id);
    expect($post->status)->toBe('draft');
});

test('a new post can be created with an account, title, status, and schedule', function () {
    $site = Site::factory()->create();
    $account = SocialAccount::factory()->create();

    Livewire::test(Index::class)
        ->set('siteId', $site->id)
        ->set('socialAccountId', $account->id)
        ->set('type', 'twitter')
        ->set('title', 'Launch')
        ->set('body', 'We launched')
        ->set('status', 'scheduled')
        ->set('scheduledAt', '2026-08-01T09:00')
        ->call('save');

    $post = Post::where('title', 'Launch')->first();
    expect($post->social_account_id)->toBe($account->id);
    expect($post->status)->toBe('scheduled');
    expect($post->scheduled_at)->not->toBeNull();
});

test('site, type, and body are required to create a post', function () {
    Livewire::test(Index::class)
        ->set('siteId', '')
        ->set('type', '')
        ->set('body', '')
        ->call('save')
        ->assertHasErrors(['siteId', 'type', 'body']);
});

test('an existing post can be edited', function () {
    $site = Site::factory()->create();
    $post = Post::factory()->create(['site_id' => $site->id, 'title' => 'Old Title']);

    Livewire::test(Index::class)
        ->call('edit', $post->id)
        ->set('title', 'New Title')
        ->call('save');

    expect($post->fresh()->title)->toBe('New Title');
});

test('a post can be deleted', function () {
    $post = Post::factory()->create();

    Livewire::test(Index::class)->call('delete', $post->id);

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

Run: docker compose exec laravel.test php artisan test tests/Feature/Livewire/PostsTest.php Expected: FAIL — route posts.index not defined / component class not found.

  • Step 3: Create the Livewire component

Run: docker compose exec laravel.test php artisan make:livewire Posts/Index --class --no-interaction

(This project uses Livewire 4.3.3, where make:livewire defaults to a single-file component unless --class is passed. Use --class to get separate app/Livewire/Posts/Index.php and resources/views/livewire/posts/index.blade.php files, matching the existing Sites/SocialAccounts pattern.)

Replace app/Livewire/Posts/Index.php with:

<?php

namespace App\Livewire\Posts;

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

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

    public ?int $editingId = null;

    public string $siteId = '';

    public string $socialAccountId = '';

    public string $type = '';

    public string $title = '';

    public string $body = '';

    public string $status = 'draft';

    public string $scheduledAt = '';

    public function render()
    {
        return view('livewire.posts.index', [
            'posts' => Post::with(['site', 'socialAccount'])->orderByDesc('created_at')->get(),
            'sites' => Site::orderBy('name')->get(),
            'accounts' => SocialAccount::orderBy('username')->get(),
            'types' => Post::TYPES,
            'statuses' => Post::STATUSES,
        ]);
    }

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

    public function edit(int $id): void
    {
        $post = Post::findOrFail($id);
        $this->editingId = $post->id;
        $this->siteId = (string) $post->site_id;
        $this->socialAccountId = $post->social_account_id ? (string) $post->social_account_id : '';
        $this->type = $post->type;
        $this->title = (string) $post->title;
        $this->body = $post->body;
        $this->status = $post->status;
        $this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? '';
        $this->showForm = true;
    }

    public function save(): void
    {
        $validated = $this->validate([
            'siteId' => ['required', 'integer', 'exists:sites,id'],
            'socialAccountId' => ['nullable', 'integer', 'exists:social_accounts,id'],
            'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)],
            'title' => ['nullable', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)],
            'scheduledAt' => ['nullable', 'date'],
        ]);

        Post::updateOrCreate(['id' => $this->editingId], [
            'site_id' => $validated['siteId'],
            'social_account_id' => $validated['socialAccountId'] ?: null,
            'type' => $validated['type'],
            'title' => $validated['title'] ?: null,
            'body' => $validated['body'],
            'status' => $validated['status'],
            'scheduled_at' => $validated['scheduledAt'] ?: null,
        ]);

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

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

    public function resetForm(): void
    {
        $this->editingId = null;
        $this->siteId = '';
        $this->socialAccountId = '';
        $this->type = '';
        $this->title = '';
        $this->body = '';
        $this->status = 'draft';
        $this->scheduledAt = '';
        $this->resetValidation();
    }
}
  • Step 4: Create the Blade view

Create resources/views/livewire/posts/index.blade.php:

<div>
    <div class="flex justify-between items-center mb-4">
        <h1 class="text-xl font-semibold">Posts</h1>
        <button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Post</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">Site</th>
                <th class="p-3">Type</th>
                <th class="p-3">Title</th>
                <th class="p-3">Status</th>
                <th class="p-3">Scheduled</th>
                <th class="p-3">Account</th>
                <th class="p-3"></th>
            </tr>
        </thead>
        <tbody>
            @foreach ($posts as $post)
                <tr wire:key="post-{{ $post->id }}" class="border-b border-gray-100">
                    <td class="p-3">{{ $post->site->name }}</td>
                    <td class="p-3">{{ ucfirst($post->type) }}</td>
                    <td class="p-3">{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}</td>
                    <td class="p-3">{{ ucfirst($post->status) }}</td>
                    <td class="p-3">{{ $post->scheduled_at?->format('Y-m-d H:i') ?? '—' }}</td>
                    <td class="p-3">{{ $post->socialAccount?->username ?? '—' }}</td>
                    <td class="p-3 text-right space-x-2">
                        <button wire:click="edit({{ $post->id }})" class="text-blue-600">Edit</button>
                        <button wire:click="delete({{ $post->id }})" wire:confirm="Delete this post?" 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 max-h-[90vh] overflow-y-auto">
                <h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Post' : 'Add Post' }}</h2>

                <div>
                    <label class="block text-sm font-medium">Site</label>
                    <select wire:model="siteId" class="w-full border rounded p-2">
                        <option value="">Select site</option>
                        @foreach ($sites as $site)
                            <option value="{{ $site->id }}">{{ $site->name }}</option>
                        @endforeach
                    </select>
                    @error('siteId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                </div>

                <div>
                    <label class="block text-sm font-medium">Social Account (optional)</label>
                    <select wire:model="socialAccountId" class="w-full border rounded p-2">
                        <option value="">None</option>
                        @foreach ($accounts as $account)
                            <option value="{{ $account->id }}">{{ $account->username }} ({{ ucfirst($account->network) }})</option>
                        @endforeach
                    </select>
                    @error('socialAccountId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                </div>

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

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

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

                <div>
                    <label class="block text-sm font-medium">Status</label>
                    <select wire:model="status" class="w-full border rounded p-2">
                        @foreach ($statuses as $s)
                            <option value="{{ $s }}">{{ ucfirst($s) }}</option>
                        @endforeach
                    </select>
                    @error('status') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
                </div>

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

                <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: Add the nav link to the shared layout

In resources/views/components/layouts/app.blade.php, change:

    <nav class="bg-white border-b border-gray-200 px-6 py-4 flex gap-6">
        <a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
        <a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
    </nav>

to:

    <nav class="bg-white border-b border-gray-200 px-6 py-4 flex gap-6">
        <a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
        <a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
        <a href="{{ route('posts.index') }}" class="font-medium hover:text-blue-600">Posts</a>
    </nav>
  • Step 6: Register the route

In routes/web.php, change:

<?php

use App\Livewire\Sites\Index as SitesIndex;
use App\Livewire\SocialAccounts\Index as SocialAccountsIndex;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/sites', SitesIndex::class)->name('sites.index');
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');

to:

<?php

use App\Livewire\Posts\Index as PostsIndex;
use App\Livewire\Sites\Index as SitesIndex;
use App\Livewire\SocialAccounts\Index as SocialAccountsIndex;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/sites', SitesIndex::class)->name('sites.index');
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');
Route::get('/posts', PostsIndex::class)->name('posts.index');
  • Step 7: Run test to verify it passes

Run: docker compose exec laravel.test php artisan test tests/Feature/Livewire/PostsTest.php Expected: PASS (6 tests)

  • Step 8: Run the full test suite to confirm no regressions in Sites/SocialAccounts pages (their nav now includes the new Posts link)

Run: docker compose exec laravel.test php artisan test Expected: PASS — all existing tests plus the 6 new ones remain green.

  • Step 9: Commit
git add app/Livewire/Posts resources/views/livewire/posts resources/views/components/layouts/app.blade.php routes/web.php tests/Feature/Livewire/PostsTest.php
git commit -m "feat: add Posts Livewire CRUD page"

Task 3: Posts API (full CRUD)

Files:

  • Create: app/Http/Controllers/Api/PostController.php
  • Create: app/Http/Resources/PostResource.php
  • Create: app/Http/Requests/StorePostRequest.php
  • Create: app/Http/Requests/UpdatePostRequest.php
  • Modify: routes/api.php
  • Test: tests/Feature/Api/PostsApiTest.php

Interfaces:

  • Consumes: App\Models\Post (Task 1, incl. TYPES/STATUSES constants), App\Models\Site, App\Models\SocialAccount.

  • Produces: GET /api/posts (list; filters ?status=, ?site_id=, ?social_account_id=, ?due=1), GET /api/posts/{post} (show), POST /api/posts (create), PUT/PATCH /api/posts/{post} (update), DELETE /api/posts/{post} (delete). JSON shape: {id, site_id, social_account_id, type, title, body, status, scheduled_at, created_at, updated_at}.

  • Step 1: Write the failing test

Create tests/Feature/Api/PostsApiTest.php:

<?php

use App\Models\Post;
use App\Models\Site;
use App\Models\SocialAccount;

test('it lists all posts', function () {
    Post::factory()->count(3)->create();

    $response = $this->getJson('/api/posts');

    $response->assertOk()->assertJsonCount(3, 'data');
});

test('it filters posts by status', function () {
    Post::factory()->create(['status' => 'draft']);
    Post::factory()->create(['status' => 'published']);

    $response = $this->getJson('/api/posts?status=published');

    $response->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.status', 'published');
});

test('it filters posts by site_id', function () {
    $site = Site::factory()->create();
    Post::factory()->create(['site_id' => $site->id]);
    Post::factory()->create();

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

    $response->assertOk()->assertJsonCount(1, 'data');
});

test('it filters posts by social_account_id', function () {
    $account = SocialAccount::factory()->create();
    Post::factory()->create(['social_account_id' => $account->id]);
    Post::factory()->create(['social_account_id' => null]);

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

    $response->assertOk()->assertJsonCount(1, 'data');
});

test('it filters posts that are due for publishing', function () {
    Post::factory()->create(['status' => 'scheduled', 'scheduled_at' => now()->subHour()]);
    Post::factory()->create(['status' => 'scheduled', 'scheduled_at' => now()->addDay()]);
    Post::factory()->create(['status' => 'draft', 'scheduled_at' => now()->subHour()]);

    $response = $this->getJson('/api/posts?due=1');

    $response->assertOk()->assertJsonCount(1, 'data');
});

test('it shows a single post', function () {
    $post = Post::factory()->create(['title' => 'Solo Post']);

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

    $response->assertOk()->assertJsonPath('data.title', 'Solo Post');
});

test('it returns 404 for a missing post', function () {
    $this->getJson('/api/posts/999')->assertNotFound();
});

test('it creates a post with required fields only', function () {
    $site = Site::factory()->create();

    $response = $this->postJson('/api/posts', [
        'site_id' => $site->id,
        'type' => 'blog',
        'body' => 'New content',
    ]);

    $response->assertCreated()
        ->assertJsonPath('data.site_id', $site->id)
        ->assertJsonPath('data.type', 'blog')
        ->assertJsonPath('data.body', 'New content')
        ->assertJsonPath('data.status', 'draft');

    expect(Post::where('body', 'New content')->exists())->toBeTrue();
});

test('it rejects creating a post without a valid site_id', function () {
    $response = $this->postJson('/api/posts', [
        'type' => 'blog',
        'body' => 'New content',
    ]);

    $response->assertUnprocessable()->assertJsonValidationErrors(['site_id']);
});

test('it rejects creating a post without a valid type', function () {
    $site = Site::factory()->create();

    $response = $this->postJson('/api/posts', [
        'site_id' => $site->id,
        'type' => 'not-a-real-type',
        'body' => 'New content',
    ]);

    $response->assertUnprocessable()->assertJsonValidationErrors(['type']);
});

test('it rejects creating a post without a body', function () {
    $site = Site::factory()->create();

    $response = $this->postJson('/api/posts', [
        'site_id' => $site->id,
        'type' => 'blog',
    ]);

    $response->assertUnprocessable()->assertJsonValidationErrors(['body']);
});

test('it updates a post status', function () {
    $post = Post::factory()->create(['status' => 'scheduled']);

    $response = $this->patchJson("/api/posts/{$post->id}", [
        'status' => 'published',
    ]);

    $response->assertOk()->assertJsonPath('data.status', 'published');
    expect($post->fresh()->status)->toBe('published');
});

test('it deletes a post', function () {
    $post = Post::factory()->create();

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

    $response->assertNoContent();
    expect(Post::find($post->id))->toBeNull();
});
  • Step 2: Run test to verify it fails

Run: docker compose exec laravel.test php artisan test tests/Feature/Api/PostsApiTest.php Expected: FAIL — routes not found.

  • Step 3: Create the Form Request classes

Run: docker compose exec laravel.test php artisan make:request StorePostRequest Run: docker compose exec laravel.test php artisan make:request UpdatePostRequest

Replace app/Http/Requests/StorePostRequest.php with:

<?php

namespace App\Http\Requests;

use App\Models\Post;
use Illuminate\Foundation\Http\FormRequest;

class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'site_id' => ['required', 'integer', 'exists:sites,id'],
            'social_account_id' => ['nullable', 'integer', 'exists:social_accounts,id'],
            'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)],
            'title' => ['nullable', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
            'scheduled_at' => ['nullable', 'date'],
        ];
    }
}

Replace app/Http/Requests/UpdatePostRequest.php with:

<?php

namespace App\Http\Requests;

use App\Models\Post;
use Illuminate\Foundation\Http\FormRequest;

class UpdatePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'site_id' => ['sometimes', 'required', 'integer', 'exists:sites,id'],
            'social_account_id' => ['nullable', 'integer', 'exists:social_accounts,id'],
            'type' => ['sometimes', 'required', 'string', 'in:' . implode(',', Post::TYPES)],
            'title' => ['nullable', 'string', 'max:255'],
            'body' => ['sometimes', 'required', 'string'],
            'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)],
            'scheduled_at' => ['nullable', 'date'],
        ];
    }
}

authorize() returns true in both — consistent with the project's no-auth-anywhere constraint (there is no user/permission system to check against).

  • Step 4: Create the API Resource

Create app/Http/Resources/PostResource.php:

<?php

namespace App\Http\Resources;

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

class PostResource extends JsonResource
{
    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,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}
  • Step 5: Create the controller

Create app/Http/Controllers/Api/PostController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(Request $request)
    {
        $query = Post::query();

        if ($request->filled('status')) {
            $query->where('status', $request->string('status'));
        }

        if ($request->filled('site_id')) {
            $query->where('site_id', $request->integer('site_id'));
        }

        if ($request->filled('social_account_id')) {
            $query->where('social_account_id', $request->integer('social_account_id'));
        }

        if ($request->boolean('due')) {
            $query->where('status', 'scheduled')
                ->where('scheduled_at', '<=', now());
        }

        return PostResource::collection($query->orderByDesc('created_at')->get());
    }

    public function show(Post $post)
    {
        return new PostResource($post);
    }

    public function store(StorePostRequest $request)
    {
        $post = Post::create($request->validated());

        return (new PostResource($post))->response()->setStatusCode(201);
    }

    public function update(UpdatePostRequest $request, Post $post)
    {
        $post->update($request->validated());

        return new PostResource($post);
    }

    public function destroy(Post $post)
    {
        $post->delete();

        return response()->noContent();
    }
}
  • Step 6: Add routes to routes/api.php

Update routes/api.php to:

<?php

use App\Http\Controllers\Api\PostController;
use App\Http\Controllers\Api\SiteController;
use App\Http\Controllers\Api\SocialAccountController;
use Illuminate\Support\Facades\Route;

Route::get('/sites', [SiteController::class, 'index']);
Route::get('/sites/{site}', [SiteController::class, 'show']);

Route::get('/accounts', [SocialAccountController::class, 'index']);
Route::get('/accounts/{socialAccount}', [SocialAccountController::class, 'show']);

Route::apiResource('posts', PostController::class);

Route::apiResource registers all five REST actions (index, store, show, update, destroy) under /posts, matching the {post} route-model-binding parameter name the controller expects.

  • Step 7: Run test to verify it passes

Run: docker compose exec laravel.test php artisan test tests/Feature/Api/PostsApiTest.php Expected: PASS (13 tests)

  • Step 8: Run the full test suite

Run: docker compose exec laravel.test php artisan test Expected: PASS — all tests across the app (Sites, SocialAccounts, Posts — UI and API) pass with no regressions.

  • Step 9: Commit
git add app/Http/Controllers/Api/PostController.php app/Http/Resources/PostResource.php app/Http/Requests/StorePostRequest.php app/Http/Requests/UpdatePostRequest.php routes/api.php tests/Feature/Api/PostsApiTest.php
git commit -m "feat: add full CRUD Posts API"

Self-Review Notes

  • Spec coverage: Post model with required site_id/type/body, optional social_account_id/title/scheduled_at, defaulted status (Task 1); cascade/null-on-delete relationship behavior (Task 1, migration + tests); independent type vocabulary reusing SocialAccount::NETWORKS plus blog (Task 1); Livewire CRUD UI as a new top-level nav section (Task 2); full read/write API with status/site_id/social_account_id/due filters (Task 3) — all spec requirements are covered.
  • Placeholder scan: No TBD/TODO markers; every step has concrete, complete code.
  • Type consistency: Post fillable fields (site_id, social_account_id, type, title, body, status, scheduled_at) and the TYPES/STATUSES constants are used consistently across the model, factory, Livewire component (camelCase property names map to snake_case DB columns explicitly in save()/edit()), Form Requests, and API resource/controller. Post::TYPES and Post::STATUSES naming mirrors the existing SocialAccount::NETWORKS/SocialAccount::TYPES convention.