46 KiB
Sites & Social Accounts Manager 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 a Livewire-based CRUD UI for two independent resources (Sites, Social Accounts) plus a read-only, unauthenticated JSON API for both, so an AI agent can later consume the data over the local network.
Architecture: Standard Laravel 13 app. Two Eloquent models (Site, SocialAccount) each with their own migration, Livewire full-page components for CRUD (index + modal form per resource), and lightweight Illuminate\Routing\Controller + API Resource classes for the read-only /api/sites and /api/accounts endpoints. No auth middleware anywhere. Tests use Pest with RefreshDatabase against the existing MySQL connection (test DB testing, configured in phpunit.xml).
Tech Stack: Laravel 13, PHP 8.4, Livewire 3, Pest 4 (pestphp/pest-plugin-laravel), MySQL, Tailwind 4 (already installed) for styling.
Global Constraints
- No authentication/authorization anywhere (UI or API) — local network only, per spec.
- Passwords stored and returned as plain text — no hashing/encryption, per spec.
- Sites and Social Accounts are fully independent — no foreign key or relationship between them.
- API is read-only: only
index/showroutes exist for/api/sitesand/api/accounts. No store/update/destroy API routes. networkenum values:twitter,instagram,facebook,reddit,tiktok,linkedin,youtube,other.typeenum values:post,comment,like, ornull.
File Structure
database/migrations/xxxx_create_sites_table.php— newdatabase/migrations/xxxx_create_social_accounts_table.php— newapp/Models/Site.php— newapp/Models/SocialAccount.php— newdatabase/factories/SiteFactory.php— newdatabase/factories/SocialAccountFactory.php— newapp/Livewire/Sites/Index.php+resources/views/livewire/sites/index.blade.php— new (list + create/edit/delete UI)app/Livewire/SocialAccounts/Index.php+resources/views/livewire/social-accounts/index.blade.php— newresources/views/components/layouts/app.blade.php— new (shared nav layout)routes/web.php— modify (add Livewire page routes)app/Http/Controllers/Api/SiteController.php— newapp/Http/Controllers/Api/SocialAccountController.php— newapp/Http/Resources/SiteResource.php— newapp/Http/Resources/SocialAccountResource.php— newroutes/api.php— new (doesn't exist yet, needs creating + registering)bootstrap/app.php— modify (registerapi.phproutes)tests/Pest.php— modify (enableRefreshDatabasefor Feature tests)tests/Feature/Livewire/SitesTest.php— newtests/Feature/Livewire/SocialAccountsTest.php— newtests/Feature/Api/SitesApiTest.php— newtests/Feature/Api/SocialAccountsApiTest.php— new
Task 1: Install Livewire and enable RefreshDatabase for tests
Files:
- Modify:
composer.json(via composer require) - Modify:
tests/Pest.php
Interfaces:
-
Produces: Livewire package available (
Livewire\Component,Livewire\Livewiretest helper),RefreshDatabaseapplied to all Feature tests. -
Step 1: Install Livewire
Run: composer require livewire/livewire
Expected: package installs cleanly, vendor/livewire exists.
- Step 2: Enable RefreshDatabase in Pest config
In tests/Pest.php, change:
pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class)
->in('Feature');
to:
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature');
- Step 3: Verify the test DB exists and the existing example test still passes
Run: php artisan db:show to confirm connection, then:
Run: php artisan test tests/Feature/ExampleTest.php
Expected: PASS (1 test). If the testing database doesn't exist, create it first: mysql -h 127.0.0.1 -u sail -ppassword -e "CREATE DATABASE IF NOT EXISTS testing" (adjust host/credentials to match your local MySQL setup — check .env and phpunit.xml for the actual values in use).
- Step 4: Commit
git add composer.json composer.lock tests/Pest.php
git commit -m "chore: install livewire, enable RefreshDatabase for feature tests"
Task 2: Site model, migration, and factory
Files:
- Create:
database/migrations/xxxx_create_sites_table.php(viaphp artisan make:migration) - Create:
app/Models/Site.php - Create:
database/factories/SiteFactory.php - Test:
tests/Feature/SiteModelTest.php
Interfaces:
-
Produces:
Sitemodel with fillablename,url,description;SiteFactoryproducing valid random sites for tests. -
Step 1: Generate the migration
Run: php artisan make:migration create_sites_table
- 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('sites', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('url');
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sites');
}
};
- Step 3: Write the failing model test
Create tests/Feature/SiteModelTest.php:
<?php
use App\Models\Site;
test('a site can be created with name, url, and optional description', function () {
$site = Site::create([
'name' => 'My Blog',
'url' => 'https://example.com',
'description' => 'A blog about things',
]);
expect($site->fresh())
->name->toBe('My Blog')
->url->toBe('https://example.com')
->description->toBe('A blog about things');
});
test('a site can be created without a description', function () {
$site = Site::create([
'name' => 'My Blog',
'url' => 'https://example.com',
]);
expect($site->fresh()->description)->toBeNull();
});
- Step 4: Run test to verify it fails
Run: php artisan test tests/Feature/SiteModelTest.php
Expected: FAIL — Site class not found, or table doesn't exist.
- Step 5: Create the Site model
Create app/Models/Site.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Site extends Model
{
use HasFactory;
protected $fillable = [
'name',
'url',
'description',
];
}
- Step 6: Create the factory
Create database/factories/SiteFactory.php:
<?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(),
];
}
}
- Step 7: Run migrations and test to verify it passes
Run: php artisan migrate
Run: php artisan test tests/Feature/SiteModelTest.php
Expected: PASS (2 tests)
- Step 8: Commit
git add database/migrations database/factories/SiteFactory.php app/Models/Site.php tests/Feature/SiteModelTest.php
git commit -m "feat: add Site model, migration, and factory"
Task 3: SocialAccount model, migration, and factory
Files:
- Create:
database/migrations/xxxx_create_social_accounts_table.php - Create:
app/Models/SocialAccount.php - Create:
database/factories/SocialAccountFactory.php - Test:
tests/Feature/SocialAccountModelTest.php
Interfaces:
-
Produces:
SocialAccountmodel with fillablenetwork,username,email,password,type; constantSocialAccount::NETWORKSandSocialAccount::TYPESarrays for use in Livewire dropdowns (Task 5) and validation;SocialAccountFactory. -
Step 1: Generate the migration
Run: php artisan make:migration create_social_accounts_table
- Step 2: Write the migration
<?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('social_accounts', function (Blueprint $table) {
$table->id();
$table->enum('network', [
'twitter', 'instagram', 'facebook', 'reddit',
'tiktok', 'linkedin', 'youtube', 'other',
]);
$table->string('username');
$table->string('email');
$table->string('password');
$table->enum('type', ['post', 'comment', 'like'])->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('social_accounts');
}
};
- Step 3: Write the failing model test
Create tests/Feature/SocialAccountModelTest.php:
<?php
use App\Models\SocialAccount;
test('a social account can be created with all required fields and a type', function () {
$account = SocialAccount::create([
'network' => 'twitter',
'username' => 'my_handle',
'email' => 'handle@example.com',
'password' => 'plain-text-password',
'type' => 'post',
]);
expect($account->fresh())
->network->toBe('twitter')
->username->toBe('my_handle')
->email->toBe('handle@example.com')
->password->toBe('plain-text-password')
->type->toBe('post');
});
test('a social account can be created with a null type', function () {
$account = SocialAccount::create([
'network' => 'instagram',
'username' => 'my_handle',
'email' => 'handle@example.com',
'password' => 'plain-text-password',
]);
expect($account->fresh()->type)->toBeNull();
});
- Step 4: Run test to verify it fails
Run: php artisan test tests/Feature/SocialAccountModelTest.php
Expected: FAIL — SocialAccount class not found.
- Step 5: Create the SocialAccount model
Create app/Models/SocialAccount.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SocialAccount extends Model
{
use HasFactory;
public const NETWORKS = [
'twitter', 'instagram', 'facebook', 'reddit',
'tiktok', 'linkedin', 'youtube', 'other',
];
public const TYPES = ['post', 'comment', 'like'];
protected $fillable = [
'network',
'username',
'email',
'password',
'type',
];
}
- Step 6: Create the factory
Create database/factories/SocialAccountFactory.php:
<?php
namespace Database\Factories;
use App\Models\SocialAccount;
use Illuminate\Database\Eloquent\Factories\Factory;
class SocialAccountFactory extends Factory
{
protected $model = SocialAccount::class;
public function definition(): array
{
return [
'network' => fake()->randomElement(SocialAccount::NETWORKS),
'username' => fake()->userName(),
'email' => fake()->safeEmail(),
'password' => fake()->password(),
'type' => fake()->optional()->randomElement(SocialAccount::TYPES),
];
}
}
- Step 7: Run migrations and test to verify it passes
Run: php artisan migrate
Run: php artisan test tests/Feature/SocialAccountModelTest.php
Expected: PASS (2 tests)
- Step 8: Commit
git add database/migrations database/factories/SocialAccountFactory.php app/Models/SocialAccount.php tests/Feature/SocialAccountModelTest.php
git commit -m "feat: add SocialAccount model, migration, and factory"
Task 4: Shared app layout
Files:
- Create:
resources/views/components/layouts/app.blade.php
Interfaces:
-
Produces:
<x-layouts.app>Blade component with a$slotand atitleprop, providing@livewireStyles/@livewireScriptsand a top nav linking to Sites and Social Accounts pages. Later Livewire full-page components render inside this via#[Layout('components.layouts.app')]. -
Step 1: Create the layout
Create resources/views/components/layouts/app.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Muninn' }}</title>
@vite('resources/css/app.css')
@livewireStyles
</head>
<body class="bg-gray-50 text-gray-900">
<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>
<main class="p-6">
{{ $slot }}
</main>
@livewireScripts
</body>
</html>
- Step 2: Verify
resources/css/app.cssexists (Tailwind entrypoint)
Run: cat resources/css/app.css 2>&1 || echo MISSING
If missing, create resources/css/app.css:
@import "tailwindcss";
And confirm vite.config.js already lists it as an input (check the file — Laravel's default Vite config typically includes resources/css/app.css and resources/js/app.js). If it's not listed, add it to the laravel-vite-plugin input array in vite.config.js.
- Step 3: Commit
git add resources/views/components/layouts/app.blade.php resources/css/app.css vite.config.js
git commit -m "feat: add shared app layout with nav"
(Note: route('sites.index') and route('social-accounts.index') won't resolve until Task 5/6 add those routes — this task alone won't be independently testable in the browser, but it introduces no failing automated test since no test targets this file directly. It will be exercised by Task 5's tests.)
Task 5: Sites Livewire CRUD
Files:
- Create:
app/Livewire/Sites/Index.php - Create:
resources/views/livewire/sites/index.blade.php - Modify:
routes/web.php - Test:
tests/Feature/Livewire/SitesTest.php
Interfaces:
-
Consumes:
App\Models\Site(Task 2),components.layouts.applayout (Task 4). -
Produces: route named
sites.indexat/sites; Livewire componentApp\Livewire\Sites\Indexwith public properties$name,$url,$description,$editingId,$showForm, and methodscreate(),edit(int $id),save(),delete(int $id),resetForm(). -
Step 1: Write the failing feature test
Create tests/Feature/Livewire/SitesTest.php:
<?php
use App\Livewire\Sites\Index;
use App\Models\Site;
use Livewire\Livewire;
test('sites index page lists existing sites', function () {
$site = Site::factory()->create(['name' => 'Example Site']);
$this->get(route('sites.index'))
->assertOk()
->assertSee('Example Site');
});
test('a new site can be created', function () {
Livewire::test(Index::class)
->set('name', 'New Site')
->set('url', 'https://newsite.test')
->set('description', 'Some description')
->call('save');
expect(Site::where('name', 'New Site')->exists())->toBeTrue();
});
test('name and url are required to create a site', function () {
Livewire::test(Index::class)
->set('name', '')
->set('url', '')
->call('save')
->assertHasErrors(['name', 'url']);
});
test('an existing site can be edited', function () {
$site = Site::factory()->create(['name' => 'Old Name']);
Livewire::test(Index::class)
->call('edit', $site->id)
->set('name', 'Updated Name')
->call('save');
expect($site->fresh()->name)->toBe('Updated Name');
});
test('a site can be deleted', function () {
$site = Site::factory()->create();
Livewire::test(Index::class)->call('delete', $site->id);
expect(Site::find($site->id))->toBeNull();
});
- Step 2: Run test to verify it fails
Run: php artisan test tests/Feature/Livewire/SitesTest.php
Expected: FAIL — route sites.index not defined / component class not found.
- Step 3: Create the Livewire component
Run: php artisan make:livewire Sites/Index --test=false (or create files manually if the command scaffolds unwanted content)
Replace app/Livewire/Sites/Index.php 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 string $name = '';
public string $url = '';
public ?string $description = null;
public function render()
{
return view('livewire.sites.index', [
'sites' => Site::orderBy('name')->get(),
]);
}
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->showForm = true;
}
public function save(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'url' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
]);
Site::updateOrCreate(['id' => $this->editingId], $validated);
$this->resetForm();
$this->showForm = false;
}
public function delete(int $id): void
{
Site::findOrFail($id)->delete();
}
public function resetForm(): void
{
$this->editingId = null;
$this->name = '';
$this->url = '';
$this->description = null;
$this->resetValidation();
}
}
- Step 4: Create the Blade view
Create resources/views/livewire/sites/index.blade.php:
<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"></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 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 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: Register the route
In routes/web.php, replace the file contents with:
<?php
use App\Livewire\Sites\Index as SitesIndex;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/sites', SitesIndex::class)->name('sites.index');
- Step 6: Run test to verify it passes
Run: php artisan test tests/Feature/Livewire/SitesTest.php
Expected: PASS (5 tests) — note sites.index GET test will still hit route('social-accounts.index') in the layout via the nav; that route is added in Task 6, so temporarily this test will fail with "Route [social-accounts.index] not defined" when rendering the layout.
To unblock Task 5 independently, register a placeholder route now so the nav link resolves, replacing it properly in Task 6:
Route::get('/social-accounts', SitesIndex::class)->name('social-accounts.index'); // placeholder, replaced in Task 6
Add this line to routes/web.php temporarily, rerun the test, confirm PASS, then proceed to Task 6 which replaces the placeholder with the real component.
- Step 7: Commit
git add app/Livewire/Sites resources/views/livewire/sites routes/web.php tests/Feature/Livewire/SitesTest.php
git commit -m "feat: add Sites Livewire CRUD page"
Task 6: Social Accounts Livewire CRUD
Files:
- Create:
app/Livewire/SocialAccounts/Index.php - Create:
resources/views/livewire/social-accounts/index.blade.php - Modify:
routes/web.php(replace Task 5's placeholder route) - Test:
tests/Feature/Livewire/SocialAccountsTest.php
Interfaces:
-
Consumes:
App\Models\SocialAccount(Task 3, incl.NETWORKS/TYPESconstants),components.layouts.applayout (Task 4). -
Produces: route named
social-accounts.indexat/social-accounts; Livewire componentApp\Livewire\SocialAccounts\Indexwith public properties$network,$username,$email,$password,$type,$editingId,$showForm,$revealedId, and methodscreate(),edit(int $id),save(),delete(int $id),resetForm(),toggleReveal(int $id). -
Step 1: Write the failing feature test
Create tests/Feature/Livewire/SocialAccountsTest.php:
<?php
use App\Livewire\SocialAccounts\Index;
use App\Models\SocialAccount;
use Livewire\Livewire;
test('social accounts index page lists existing accounts', function () {
SocialAccount::factory()->create(['username' => 'example_user']);
$this->get(route('social-accounts.index'))
->assertOk()
->assertSee('example_user');
});
test('a new social account can be created', function () {
Livewire::test(Index::class)
->set('network', 'twitter')
->set('username', 'new_user')
->set('email', 'new_user@example.com')
->set('password', 'secret123')
->set('type', 'post')
->call('save');
expect(SocialAccount::where('username', 'new_user')->exists())->toBeTrue();
});
test('network, username, email, and password are required', function () {
Livewire::test(Index::class)
->set('network', '')
->set('username', '')
->set('email', '')
->set('password', '')
->call('save')
->assertHasErrors(['network', 'username', 'email', 'password']);
});
test('type can be left null', function () {
Livewire::test(Index::class)
->set('network', 'reddit')
->set('username', 'no_type_user')
->set('email', 'no_type@example.com')
->set('password', 'secret123')
->set('type', null)
->call('save');
$account = SocialAccount::where('username', 'no_type_user')->first();
expect($account->type)->toBeNull();
});
test('an existing account can be edited', function () {
$account = SocialAccount::factory()->create(['username' => 'old_name']);
Livewire::test(Index::class)
->call('edit', $account->id)
->set('username', 'new_name')
->call('save');
expect($account->fresh()->username)->toBe('new_name');
});
test('an account can be deleted', function () {
$account = SocialAccount::factory()->create();
Livewire::test(Index::class)->call('delete', $account->id);
expect(SocialAccount::find($account->id))->toBeNull();
});
- Step 2: Run test to verify it fails
Run: php artisan test tests/Feature/Livewire/SocialAccountsTest.php
Expected: FAIL — route/component not found.
- Step 3: Create the Livewire component
Create app/Livewire/SocialAccounts/Index.php:
<?php
namespace App\Livewire\SocialAccounts;
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 ?int $revealedId = null;
public string $network = '';
public string $username = '';
public string $email = '';
public string $password = '';
public ?string $type = null;
public function render()
{
return view('livewire.social-accounts.index', [
'accounts' => SocialAccount::orderBy('username')->get(),
'networks' => SocialAccount::NETWORKS,
'types' => SocialAccount::TYPES,
]);
}
public function create(): void
{
$this->resetForm();
$this->showForm = true;
}
public function edit(int $id): void
{
$account = SocialAccount::findOrFail($id);
$this->editingId = $account->id;
$this->network = $account->network;
$this->username = $account->username;
$this->email = $account->email;
$this->password = $account->password;
$this->type = $account->type;
$this->showForm = true;
}
public function save(): void
{
$validated = $this->validate([
'network' => ['required', 'string', 'in:' . implode(',', SocialAccount::NETWORKS)],
'username' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'max:255'],
'type' => ['nullable', 'string', 'in:' . implode(',', SocialAccount::TYPES)],
]);
SocialAccount::updateOrCreate(['id' => $this->editingId], $validated);
$this->resetForm();
$this->showForm = false;
}
public function delete(int $id): void
{
SocialAccount::findOrFail($id)->delete();
}
public function toggleReveal(int $id): void
{
$this->revealedId = $this->revealedId === $id ? null : $id;
}
public function resetForm(): void
{
$this->editingId = null;
$this->network = '';
$this->username = '';
$this->email = '';
$this->password = '';
$this->type = null;
$this->resetValidation();
}
}
- Step 4: Create the Blade view
Create resources/views/livewire/social-accounts/index.blade.php:
<div>
<div class="flex justify-between items-center mb-4">
<h1 class="text-xl font-semibold">Social Accounts</h1>
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Account</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">Network</th>
<th class="p-3">Username</th>
<th class="p-3">Email</th>
<th class="p-3">Password</th>
<th class="p-3">Type</th>
<th class="p-3"></th>
</tr>
</thead>
<tbody>
@foreach ($accounts as $account)
<tr wire:key="account-{{ $account->id }}" class="border-b border-gray-100">
<td class="p-3">{{ ucfirst($account->network) }}</td>
<td class="p-3">{{ $account->username }}</td>
<td class="p-3">{{ $account->email }}</td>
<td class="p-3">
@if ($revealedId === $account->id)
{{ $account->password }}
@else
••••••••
@endif
<button wire:click="toggleReveal({{ $account->id }})" class="text-xs text-blue-600 ml-2">
{{ $revealedId === $account->id ? 'Hide' : 'Show' }}
</button>
</td>
<td class="p-3">{{ $account->type ? ucfirst($account->type) : '—' }}</td>
<td class="p-3 text-right space-x-2">
<button wire:click="edit({{ $account->id }})" class="text-blue-600">Edit</button>
<button wire:click="delete({{ $account->id }})" wire:confirm="Delete this account?" 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 Account' : 'Add Account' }}</h2>
<div>
<label class="block text-sm font-medium">Network</label>
<select wire:model="network" class="w-full border rounded p-2">
<option value="">Select network</option>
@foreach ($networks as $network)
<option value="{{ $network }}">{{ ucfirst($network) }}</option>
@endforeach
</select>
@error('network') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Username</label>
<input type="text" wire:model="username" class="w-full border rounded p-2">
@error('username') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Email</label>
<input type="text" wire:model="email" class="w-full border rounded p-2">
@error('email') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Password</label>
<input type="text" wire:model="password" class="w-full border rounded p-2">
@error('password') <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="">None</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 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: Replace the placeholder route in
routes/web.php
Update routes/web.php to:
<?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');
- Step 6: Run both Livewire test files to verify everything passes
Run: php artisan test tests/Feature/Livewire/
Expected: PASS (11 tests total: 5 from SitesTest + 6 from SocialAccountsTest)
- Step 7: Commit
git add app/Livewire/SocialAccounts resources/views/livewire/social-accounts routes/web.php tests/Feature/Livewire/SocialAccountsTest.php
git commit -m "feat: add Social Accounts Livewire CRUD page"
Task 7: Read-only Sites API
Files:
- Create:
app/Http/Controllers/Api/SiteController.php - Create:
app/Http/Resources/SiteResource.php - Create:
routes/api.php - Modify:
bootstrap/app.php - Test:
tests/Feature/Api/SitesApiTest.php
Interfaces:
-
Consumes:
App\Models\Site(Task 2). -
Produces:
GET /api/sites(list, optional?name=partial filter),GET /api/sites/{site}(show). JSON shape:{id, name, url, description, created_at, updated_at}. -
Step 1: Write the failing test
Create tests/Feature/Api/SitesApiTest.php:
<?php
use App\Models\Site;
test('it lists all sites', function () {
Site::factory()->count(3)->create();
$response = $this->getJson('/api/sites');
$response->assertOk()->assertJsonCount(3, 'data');
});
test('it filters sites by partial name match', function () {
Site::factory()->create(['name' => 'My Awesome Blog']);
Site::factory()->create(['name' => 'Other Site']);
$response = $this->getJson('/api/sites?name=Awesome');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.name', 'My Awesome Blog');
});
test('it shows a single site', function () {
$site = Site::factory()->create(['name' => 'Solo Site']);
$response = $this->getJson("/api/sites/{$site->id}");
$response->assertOk()->assertJsonPath('data.name', 'Solo Site');
});
test('it returns 404 for a missing site', function () {
$this->getJson('/api/sites/999')->assertNotFound();
});
- Step 2: Run test to verify it fails
Run: php artisan test tests/Feature/Api/SitesApiTest.php
Expected: FAIL — 404 or route not found for /api/sites.
- Step 3: Create the API Resource
Create app/Http/Resources/SiteResource.php:
<?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,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
- Step 4: Create the controller
Create app/Http/Controllers/Api/SiteController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\SiteResource;
use App\Models\Site;
use Illuminate\Http\Request;
class SiteController extends Controller
{
public function index(Request $request)
{
$query = Site::query();
if ($request->filled('name')) {
$query->where('name', 'like', '%' . $request->string('name') . '%');
}
return SiteResource::collection($query->orderBy('name')->get());
}
public function show(Site $site)
{
return new SiteResource($site);
}
}
- Step 5: Create
routes/api.php
Create routes/api.php:
<?php
use App\Http\Controllers\Api\SiteController;
use Illuminate\Support\Facades\Route;
Route::get('/sites', [SiteController::class, 'index']);
Route::get('/sites/{site}', [SiteController::class, 'show']);
- Step 6: Register the api routes file
Read bootstrap/app.php first to see its current withRouting call, then add api: __DIR__.'/../routes/api.php', to the routing configuration. For a standard Laravel 13 skeleton, bootstrap/app.php looks like:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
Add the api key so it becomes:
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
- Step 7: Run test to verify it passes
Run: php artisan test tests/Feature/Api/SitesApiTest.php
Expected: PASS (4 tests)
- Step 8: Commit
git add app/Http/Controllers/Api/SiteController.php app/Http/Resources/SiteResource.php routes/api.php bootstrap/app.php tests/Feature/Api/SitesApiTest.php
git commit -m "feat: add read-only Sites API"
Task 8: Read-only Social Accounts API
Files:
- Create:
app/Http/Controllers/Api/SocialAccountController.php - Create:
app/Http/Resources/SocialAccountResource.php - Modify:
routes/api.php - Test:
tests/Feature/Api/SocialAccountsApiTest.php
Interfaces:
-
Consumes:
App\Models\SocialAccount(Task 3). -
Produces:
GET /api/accounts(list, optional?network=and?type=exact-match filters),GET /api/accounts/{social_account}(show). JSON shape includes plain-textpassword:{id, network, username, email, password, type, created_at, updated_at}. -
Step 1: Write the failing test
Create tests/Feature/Api/SocialAccountsApiTest.php:
<?php
use App\Models\SocialAccount;
test('it lists all social accounts including password', function () {
SocialAccount::factory()->count(3)->create();
$response = $this->getJson('/api/accounts');
$response->assertOk()->assertJsonCount(3, 'data');
expect($response->json('data.0'))->toHaveKey('password');
});
test('it filters accounts by network', function () {
SocialAccount::factory()->create(['network' => 'twitter']);
SocialAccount::factory()->create(['network' => 'instagram']);
$response = $this->getJson('/api/accounts?network=twitter');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.network', 'twitter');
});
test('it filters accounts by type', function () {
SocialAccount::factory()->create(['type' => 'post']);
SocialAccount::factory()->create(['type' => 'like']);
$response = $this->getJson('/api/accounts?type=post');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.type', 'post');
});
test('it shows a single account with password', function () {
$account = SocialAccount::factory()->create(['password' => 'super-secret']);
$response = $this->getJson("/api/accounts/{$account->id}");
$response->assertOk()->assertJsonPath('data.password', 'super-secret');
});
test('it returns 404 for a missing account', function () {
$this->getJson('/api/accounts/999')->assertNotFound();
});
- Step 2: Run test to verify it fails
Run: php artisan test tests/Feature/Api/SocialAccountsApiTest.php
Expected: FAIL — route not found.
- Step 3: Create the API Resource
Create app/Http/Resources/SocialAccountResource.php:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SocialAccountResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'network' => $this->network,
'username' => $this->username,
'email' => $this->email,
'password' => $this->password,
'type' => $this->type,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
- Step 4: Create the controller
Create app/Http/Controllers/Api/SocialAccountController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\SocialAccountResource;
use App\Models\SocialAccount;
use Illuminate\Http\Request;
class SocialAccountController extends Controller
{
public function index(Request $request)
{
$query = SocialAccount::query();
if ($request->filled('network')) {
$query->where('network', $request->string('network'));
}
if ($request->filled('type')) {
$query->where('type', $request->string('type'));
}
return SocialAccountResource::collection($query->orderBy('username')->get());
}
public function show(SocialAccount $socialAccount)
{
return new SocialAccountResource($socialAccount);
}
}
- Step 5: Add routes to
routes/api.php
Update routes/api.php to:
<?php
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']);
- Step 6: Run test to verify it passes
Run: php artisan test tests/Feature/Api/SocialAccountsApiTest.php
Expected: PASS (5 tests)
- Step 7: Run the full test suite
Run: php artisan test
Expected: PASS — all tests across the app (example tests, model tests, Livewire tests, API tests) pass.
- Step 8: Commit
git add app/Http/Controllers/Api/SocialAccountController.php app/Http/Resources/SocialAccountResource.php routes/api.php tests/Feature/Api/SocialAccountsApiTest.php
git commit -m "feat: add read-only Social Accounts API"
Task 9: Wire up the home page and manual verification
Files:
- Modify:
resources/views/welcome.blade.php
Interfaces:
-
Produces: a landing page at
/linking to/sitesand/social-accountsso the app is navigable from the root URL. -
Step 1: Replace the welcome view
Replace the entire contents of resources/views/welcome.blade.php with:
<x-layouts.app title="Muninn">
<div class="max-w-xl mx-auto text-center space-y-6 mt-20">
<h1 class="text-2xl font-bold">Muninn</h1>
<p class="text-gray-600">Manage your sites and social accounts.</p>
<div class="flex justify-center gap-4">
<a href="{{ route('sites.index') }}" class="bg-blue-600 text-white px-4 py-2 rounded">View Sites</a>
<a href="{{ route('social-accounts.index') }}" class="bg-blue-600 text-white px-4 py-2 rounded">View Social Accounts</a>
</div>
</div>
</x-layouts.app>
- Step 2: Update
tests/Feature/ExampleTest.phpto match the new welcome content
The existing test only asserts a 200 status, so no change is strictly required. Run it to confirm:
Run: php artisan test tests/Feature/ExampleTest.php
Expected: PASS
- Step 3: Manually verify in the browser
Run: npm run build (or start npm run dev in one terminal and php artisan serve in another)
Visit http://localhost:8000/ and confirm:
-
The home page links to Sites and Social Accounts
-
/sites: can add, edit, delete a site -
/social-accounts: can add, edit, delete an account; password show/hide toggle works -
GET /api/sitesandGET /api/accounts(e.g. viacurl http://localhost:8000/api/sites) return JSON matching what's in the UI -
Step 4: Commit
git add resources/views/welcome.blade.php
git commit -m "feat: add home page linking to Sites and Social Accounts"
Self-Review Notes
- Spec coverage: Sites CRUD (Task 5), Social Accounts CRUD (Task 6), read-only API with filters for both resources (Tasks 7–8), no-auth throughout (no middleware added anywhere), plain-text password storage/display (Tasks 3, 6, 8) — all spec requirements are covered.
- Placeholder scan: No TBD/TODO markers; Task 5 Step 6 contains a temporary placeholder route explicitly called out and immediately resolved in Task 6 Step 5 — this is a real, working intermediate state, not an unfinished placeholder.
- Type consistency:
Sitefillable fields (name,url,description) match across model, factory, Livewire component, and API resource.SocialAccountfillable fields (network,username,email,password,type) and theNETWORKS/TYPESconstants are consistent across model, factory, Livewire component/view, and API resource/controller.