relaticle/ink
Filament-native content publishing for Laravel: Eloquent models, full admin for posts/categories, SEO + JSON-LD, RSS, search, Blade UI components, and 13 MCP tools for AI agents. Headless by default with optional public blog routes.
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: Make relaticle/ink a drop-in replacement for the Tapix and FilaForms internal blog packages by adding opt-in public routes (controller + page views), bulk publishing actions, MCP markdown sanitization, reading-time / related-posts wiring, plus a real test suite and CI.
Architecture: Feature flags live in config/ink.php (not on the Filament plugin) so public routes register at the service-provider level — independently of any Filament panel boot. Headless behavior remains the default (features.public_routes = false would mean exactly today's behavior). Filament panel concerns stay on the plugin (resource discovery, MCP tool registration). Two-layer architecture: Core (always on) + Plus (opt-in via config).
Tech Stack: PHP 8.3+ · Laravel 12 · Filament v5 · Pest v3 · Spatie Laravel Package Tools · Spatie Sluggable · Ralph J Smit Laravel SEO · Spatie Markdown.
Created:
src/Http/Controllers/BlogController.php — public controller (index/show/category/preview/feed)src/Http/Controllers/BaseBlogController.php — not created; we keep one controller, no abstractionroutes/web.php — public route file (loaded conditionally by service provider)resources/views/layouts/blog.blade.php — wrapper that extends host layoutresources/views/pages/index.blade.phpresources/views/pages/show.blade.phpresources/views/pages/category.blade.phpresources/views/pages/preview.blade.phpresources/views/pages/feed.blade.php — RSS 2.0 page (re-uses existing <x-ink::feed> component)resources/views/pages/_post-content.blade.php — shared partial used by show + previewtests/TestCase.php — Orchestra Testbench basetests/Pest.php — pest bootstraptests/Feature/PublicRoutesTest.phptests/Feature/PostResourceBulkActionsTest.phptests/Feature/Mcp/CreatePostToolTest.phptests/Feature/PostModelTest.php — reading time, related postsphpunit.xml.distpint.json.github/workflows/tests.ymlModified:
config/ink.php — add features array, layout, tables sections, fill default feed metadatasrc/InkServiceProvider.php — read config flags, conditionally register routessrc/Filament/Resources/PostResource.php — add bulk publish/unpublish/schedule actionssrc/Mcp/Tools/CreatePostTool.php — markdown sanitizationsrc/Mcp/Tools/UpdatePostTool.php — markdown sanitizationsrc/Models/Post.php — readingTime() accessor, relatedPosts() querysrc/Components/RelatedPosts.php — call new model method, expose $relatedPosts to viewcomposer.json — require-dev: pestphp/pest, orchestra/testbench, laravel/pint, larastan/larastanREADME.md — document new flags + plugin builder additionsdocs/content/1.getting-started/2.frontend-setup.md — describe public-routes modeFiles: none (git plumbing only)
cd /tmp/filament-blog # or your local clone
git status
git branch --show-current
Expected: branch feat/public-routes-phase-1, clean working tree.
git fetch origin
git log --oneline origin/main -3
Expected: see latest main commits; if branch is behind, rebase.
Files:
Create: composer.json (modify)
Create: tests/TestCase.php
Create: tests/Pest.php
Create: phpunit.xml.dist
Create: pint.json
Step 1: Add dev dependencies
cd /tmp/filament-blog
composer require --dev pestphp/pest:^3.0 pestphp/pest-plugin-laravel:^3.0 \
orchestra/testbench:^9.0 laravel/pint:^1.14 larastan/larastan:^3.0 --no-update
composer update --no-scripts
Expected: composer.json gets a require-dev block; composer.lock updated; vendor populated.
tests/TestCase.php<?php
declare(strict_types=1);
namespace Relaticle\Ink\Tests;
use BladeUI\Heroicons\BladeHeroiconsServiceProvider;
use BladeUI\Icons\BladeIconsServiceProvider;
use Filament\Actions\ActionsServiceProvider;
use Filament\FilamentServiceProvider;
use Filament\Forms\FormsServiceProvider;
use Filament\Infolists\InfolistsServiceProvider;
use Filament\Notifications\NotificationsServiceProvider;
use Filament\Schemas\SchemasServiceProvider;
use Filament\Support\SupportServiceProvider;
use Filament\Tables\TablesServiceProvider;
use Filament\Widgets\WidgetsServiceProvider;
use Illuminate\Database\Schema\Blueprint;
use Livewire\LivewireServiceProvider;
use Relaticle\Ink\InkServiceProvider;
use Orchestra\Testbench\TestCase as BaseTestCase;
use RalphJSmit\Laravel\SEO\SEOServiceProvider as RalphSEOServiceProvider;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
class TestCase extends BaseTestCase
{
protected function getPackageProviders($app): array
{
return [
BladeIconsServiceProvider::class,
BladeHeroiconsServiceProvider::class,
FilamentServiceProvider::class,
ActionsServiceProvider::class,
FormsServiceProvider::class,
InfolistsServiceProvider::class,
NotificationsServiceProvider::class,
SchemasServiceProvider::class,
SupportServiceProvider::class,
TablesServiceProvider::class,
WidgetsServiceProvider::class,
LivewireServiceProvider::class,
RalphSEOServiceProvider::class,
InkServiceProvider::class,
];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
$app['config']->set('view.paths', [__DIR__.'/Fixtures/views']);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
// minimal users table for the author FK
$this->app['db']->connection()->getSchemaBuilder()
->create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
}
}
tests/Pest.php<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Relaticle\Ink\Tests\TestCase;
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature');
phpunit.xml.dist<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://getcomposer.org/xsd/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
executionOrder="random"
backupGlobals="false"
backupStaticProperties="false"
beStrictAboutOutputDuringTests="true"
failOnNotice="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
pint.json{
"preset": "laravel",
"rules": {
"declare_strict_types": true,
"ordered_imports": { "sort_algorithm": "alpha" },
"no_unused_imports": true
}
}
tests/Fixtures/views/.gitkeepmkdir -p /tmp/filament-blog/tests/Fixtures/views
touch /tmp/filament-blog/tests/Fixtures/views/.gitkeep
cd /tmp/filament-blog
vendor/bin/pest --version
Expected: prints Pest version, no error.
git add composer.json composer.lock tests/TestCase.php tests/Pest.php tests/Fixtures/views/.gitkeep phpunit.xml.dist pint.json
git commit -m "test: add Pest + Testbench scaffolding and Pint config"
Files:
Create: .github/workflows/tests.yml
Step 1: Write the workflow
name: Tests
on:
push:
branches: [main]
pull_request:
workflow_call:
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.3', '8.4']
laravel: ['^12.0']
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- name: Install dependencies
run: composer update --prefer-dist --no-interaction --no-progress
- name: Run tests
run: vendor/bin/pest --ci
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', coverage: none }
- run: composer update --prefer-dist --no-interaction --no-progress
- run: vendor/bin/pint --test
git add .github/workflows/tests.yml
git commit -m "ci: add tests + lint workflow"
features, layout, tables to configFiles:
Modify: config/ink.php (full rewrite — short file)
Step 1: Replace the config file
Replace config/ink.php contents with:
<?php
declare(strict_types=1);
return [
'prefix' => 'blog',
'layout' => 'layouts.app',
'author_model' => \App\Models\User::class,
'per_page' => 12,
'features' => [
'public_routes' => false,
'feed' => false,
'sitemap' => false,
],
'feed' => [
'title' => null,
'description' => null,
'author_email' => null,
],
'publisher' => [
'name' => null,
'url' => null,
'logo' => null,
],
'tables' => [
'posts' => 'blog_posts',
'categories' => 'blog_categories',
],
];
Note: defaults are false so existing installs keep their headless behavior unchanged.
git add config/ink.php
git commit -m "feat(config): add features array, layout, tables sections"
Files:
Create: tests/Feature/PublicRoutesTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
use Relaticle\Ink\Models\Category;
use Relaticle\Ink\Models\Post;
beforeEach(function () {
config()->set('ink.features.public_routes', true);
config()->set('ink.layout', 'tests::layouts.empty');
});
test('public index route returns published posts when feature enabled', function () {
$post = Post::factory()->published()->create(['title' => 'Hello world']);
$this->get(route('blog.index'))
->assertOk()
->assertSeeText('Hello world');
});
test('public index route is not registered when feature disabled', function () {
config()->set('ink.features.public_routes', false);
expect(\Illuminate\Support\Facades\Route::has('blog.index'))->toBeFalse();
});
cd /tmp/filament-blog
vendor/bin/pest tests/Feature/PublicRoutesTest.php
Expected: FAIL — route('blog.index') not defined; route helper throws.
Files:
Create: database/factories/PostFactory.php
Create: database/factories/CategoryFactory.php
Create: tests/Fixtures/views/layouts/empty.blade.php
Step 1: Create CategoryFactory
<?php
declare(strict_types=1);
namespace Relaticle\Ink\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Relaticle\Ink\Models\Category;
class CategoryFactory extends Factory
{
protected $model = Category::class;
public function definition(): array
{
$name = $this->faker->unique()->words(2, true);
return [
'name' => $name,
'slug' => \Illuminate\Support\Str::slug($name),
];
}
}
<?php
declare(strict_types=1);
namespace Relaticle\Ink\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use Relaticle\Ink\Enums\PostStatus;
use Relaticle\Ink\Models\Post;
class PostFactory extends Factory
{
protected $model = Post::class;
public function definition(): array
{
$title = $this->faker->unique()->sentence(4);
return [
'title' => $title,
'slug' => Str::slug($title),
'content' => $this->faker->paragraphs(3, true),
'excerpt' => $this->faker->sentence(),
'featured_image' => null,
'category_id' => null,
'author_id' => null,
'status' => PostStatus::Draft,
'published_at' => null,
];
}
public function published(): static
{
return $this->state(fn () => [
'status' => PostStatus::Published,
'published_at' => now()->subMinute(),
]);
}
public function scheduled(): static
{
return $this->state(fn () => [
'status' => PostStatus::Published,
'published_at' => now()->addDay(),
]);
}
}
In composer.json, ensure the factories namespace is autoloaded — add this autoload-dev:
"autoload-dev": {
"psr-4": {
"Relaticle\\Ink\\Tests\\": "tests/",
"Relaticle\\Ink\\Database\\Factories\\": "database/factories/"
}
}
Then:
composer dump-autoload
In src/Models/Post.php, ensure use HasFactory; is present and add the static newFactory() if HasFactory cannot resolve namespace:
protected static function newFactory(): \Relaticle\Ink\Database\Factories\PostFactory
{
return \Relaticle\Ink\Database\Factories\PostFactory::new();
}
Same for Category.php:
protected static function newFactory(): \Relaticle\Ink\Database\Factories\CategoryFactory
{
return \Relaticle\Ink\Database\Factories\CategoryFactory::new();
}
tests/Fixtures/views/layouts/empty.blade.php:
<!doctype html>
<html><head><title>{{ $title ?? 'Blog' }}</title></head>
<body>[@yield](https://github.com/yield)('content')</body>
</html>
In tests/TestCase.php defineEnvironment(), replace the view.paths line with a registered namespace:
$app['view']->addNamespace('tests', __DIR__.'/Fixtures/views');
(Remove the previous $app['config']->set('view.paths', ...) line.)
git add database/factories/ tests/Fixtures/views/ src/Models/ tests/TestCase.php composer.json composer.lock
git commit -m "test: add Post + Category factories and fixture layout"
Files:
Create: src/Http/Controllers/BlogController.php
Create: routes/web.php
Create: resources/views/pages/index.blade.php
Step 1: Create the controller (index only first)
<?php
declare(strict_types=1);
namespace Relaticle\Ink\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Relaticle\Ink\Models\Post;
class BlogController extends Controller
{
public function index(Request $request): View
{
$perPage = (int) config('ink.per_page', 12);
$posts = Post::query()
->with(['category', 'author', 'seo'])
->published()
->latest('published_at')
->paginate($perPage);
return view('ink::pages.index', [
'posts' => $posts,
]);
}
}
routes/web.php:
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Relaticle\Ink\Http\Controllers\BlogController;
$prefix = config('ink.prefix', 'ink');
Route::prefix($prefix)->middleware('web')->group(function () {
Route::get('/', [BlogController::class, 'index'])->name('blog.index');
});
In src/InkServiceProvider.php, replace packageBooted() with:
public function packageBooted(): void
{
Blade::componentNamespace('Relaticle\\Ink\\Components', 'ink');
if (config('ink.features.public_routes')) {
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
}
}
resources/views/pages/index.blade.php:
[@extends](https://github.com/extends)(config('ink.layout', 'layouts.app'))
[@section](https://github.com/section)('content')
<div class="max-w-3xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold mb-8">{{ config('ink.feed.title') ?? 'Blog' }}</h1>
<div class="space-y-8">
[@forelse](https://github.com/forelse) ($posts as $post)
<x-ink::post-card :post="$post" />
[@empty](https://github.com/empty)
<p class="text-gray-500">No posts yet.</p>
[@endforelse](https://github.com/endforelse)
</div>
<div class="mt-12">
{{ $posts->links() }}
</div>
</div>
[@endsection](https://github.com/endsection)
vendor/bin/pest tests/Feature/PublicRoutesTest.php
Expected: PASS for "public index route returns published posts when feature enabled" and "public index route is not registered when feature disabled".
git add src/Http/Controllers/BlogController.php routes/web.php src/InkServiceProvider.php resources/views/pages/index.blade.php
git commit -m "feat: add public blog routes and index page (config-gated)"
Files:
Modify: tests/Feature/PublicRoutesTest.php
Modify: src/Http/Controllers/BlogController.php
Modify: routes/web.php
Create: resources/views/pages/show.blade.php
Create: resources/views/pages/_post-content.blade.php
Step 1: Add failing tests
Append to tests/Feature/PublicRoutesTest.php:
test('public show route returns the post by slug', function () {
$post = Post::factory()->published()->create([
'title' => 'My Post',
'slug' => 'my-post',
'content' => 'Hello body content',
]);
$this->get(route('blog.show', 'my-post'))
->assertOk()
->assertSeeText('My Post')
->assertSeeText('Hello body content');
});
test('public show 404s on draft post', function () {
Post::factory()->create(['slug' => 'unpublished']);
$this->get(route('blog.show', 'unpublished'))->assertNotFound();
});
test('public show 404s on scheduled (future) post', function () {
Post::factory()->scheduled()->create(['slug' => 'tomorrow']);
$this->get(route('blog.show', 'tomorrow'))->assertNotFound();
});
vendor/bin/pest tests/Feature/PublicRoutesTest.php --filter="public show"
Expected: 3 failures (route('blog.show') not defined).
show controller actionIn src/Http/Controllers/BlogController.php, add:
public function show(string $slug): View
{
$post = Post::query()
->with(['category', 'author', 'seo'])
->where('slug', $slug)
->published()
->firstOrFail();
$related = $post->relatedPosts(limit: 3)->get();
return view('ink::pages.show', [
'post' => $post,
'relatedPosts' => $related,
]);
}
(relatedPosts() will be added on the Post model in Task 11; this controller method is fine to ship now.)
Add use Illuminate\Http\Response; if needed (currently not).
In routes/web.php, inside the existing prefix group, add:
Route::get('/{slug}', [BlogController::class, 'show'])->name('blog.show');
resources/views/pages/show.blade.php:
[@extends](https://github.com/extends)(config('ink.layout', 'layouts.app'))
[@section](https://github.com/section)('content')
<article class="max-w-2xl mx-auto px-4 py-12 prose dark:prose-invert">
<x-ink::post-header :post="$post" />
[@include](https://github.com/include)('blog::pages._post-content', ['post' => $post])
<x-ink::related-posts :post="$post" :relatedPosts="$relatedPosts" />
</article>
[@endsection](https://github.com/endsection)
resources/views/pages/_post-content.blade.php:
<div class="post-body">
<x-ink::post-body :post="$post" />
</div>
vendor/bin/pest tests/Feature/PublicRoutesTest.php
Expected: all 5 PASS.
git add tests/Feature/PublicRoutesTest.php src/Http/Controllers/BlogController.php routes/web.php resources/views/pages/
git commit -m "feat: add public show route and view"
Files:
tests/Feature/PublicRoutesTest.phpHow can I help you explore Laravel packages today?