Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ink Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Public Routes & Drop-in Replacement (Phase 1) 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: 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.


File Structure

Created:

  • src/Http/Controllers/BlogController.php — public controller (index/show/category/preview/feed)
  • src/Http/Controllers/BaseBlogController.phpnot created; we keep one controller, no abstraction
  • routes/web.php — public route file (loaded conditionally by service provider)
  • resources/views/layouts/blog.blade.php — wrapper that extends host layout
  • resources/views/pages/index.blade.php
  • resources/views/pages/show.blade.php
  • resources/views/pages/category.blade.php
  • resources/views/pages/preview.blade.php
  • resources/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 + preview
  • tests/TestCase.php — Orchestra Testbench base
  • tests/Pest.php — pest bootstrap
  • tests/Feature/PublicRoutesTest.php
  • tests/Feature/PostResourceBulkActionsTest.php
  • tests/Feature/Mcp/CreatePostToolTest.php
  • tests/Feature/PostModelTest.php — reading time, related posts
  • phpunit.xml.dist
  • pint.json
  • .github/workflows/tests.yml

Modified:

  • config/ink.php — add features array, layout, tables sections, fill default feed metadata
  • src/InkServiceProvider.php — read config flags, conditionally register routes
  • src/Filament/Resources/PostResource.php — add bulk publish/unpublish/schedule actions
  • src/Mcp/Tools/CreatePostTool.php — markdown sanitization
  • src/Mcp/Tools/UpdatePostTool.php — markdown sanitization
  • src/Models/Post.phpreadingTime() accessor, relatedPosts() query
  • src/Components/RelatedPosts.php — call new model method, expose $relatedPosts to view
  • composer.json — require-dev: pestphp/pest, orchestra/testbench, laravel/pint, larastan/larastan
  • README.md — document new flags + plugin builder additions
  • docs/content/1.getting-started/2.frontend-setup.md — describe public-routes mode

Setup

Task 0: Setup branch and verify clean state

Files: none (git plumbing only)

  • Step 1: Confirm branch and clean state
cd /tmp/filament-blog   # or your local clone
git status
git branch --show-current

Expected: branch feat/public-routes-phase-1, clean working tree.

  • Step 2: Pull main to be sure we're up to date
git fetch origin
git log --oneline origin/main -3

Expected: see latest main commits; if branch is behind, rebase.


Setup: tests + CI

Task 1: Add test infrastructure

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.

  • Step 2: Create 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();
            });
    }
}
  • Step 3: Create 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');
  • Step 4: Create 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>
  • Step 5: Create pint.json
{
    "preset": "laravel",
    "rules": {
        "declare_strict_types": true,
        "ordered_imports": { "sort_algorithm": "alpha" },
        "no_unused_imports": true
    }
}
  • Step 6: Create tests/Fixtures/views/.gitkeep
mkdir -p /tmp/filament-blog/tests/Fixtures/views
touch /tmp/filament-blog/tests/Fixtures/views/.gitkeep
  • Step 7: Smoke-run pest
cd /tmp/filament-blog
vendor/bin/pest --version

Expected: prints Pest version, no error.

  • Step 8: Commit
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"

Task 2: Add CI workflow

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
  • Step 2: Commit
git add .github/workflows/tests.yml
git commit -m "ci: add tests + lint workflow"

Config evolution

Task 3: Add features, layout, tables to config

Files:

  • 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.

  • Step 2: Commit
git add config/ink.php
git commit -m "feat(config): add features array, layout, tables sections"

Public routes (TDD)

Task 4: Failing test for public index route

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();
});
  • Step 2: Run the test to verify it fails
cd /tmp/filament-blog
vendor/bin/pest tests/Feature/PublicRoutesTest.php

Expected: FAIL — route('blog.index') not defined; route helper throws.


Task 5: Test fixtures (factory + layout view)

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),
        ];
    }
}
  • Step 2: Create PostFactory
<?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(),
        ]);
    }
}
  • Step 3: Wire factory autoload

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
  • Step 4: Make Post and Category use factories

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();
}
  • Step 5: Create the empty layout fixture

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>
  • Step 6: Register the fixture views path in TestCase

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.)

  • Step 7: Commit
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"

Task 6: BlogController + index page

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,
        ]);
    }
}
  • Step 2: Create the routes file

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');
});
  • Step 3: Wire route loading in service provider

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');
    }
}
  • Step 4: Create the index view

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)
  • Step 5: Run the test
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".

  • Step 6: Commit
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)"

Task 7: Show page (single post)

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();
});
  • Step 2: Run — expect fail
vendor/bin/pest tests/Feature/PublicRoutesTest.php --filter="public show"

Expected: 3 failures (route('blog.show') not defined).

  • Step 3: Add show controller action

In 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).

  • Step 4: Add the route

In routes/web.php, inside the existing prefix group, add:

Route::get('/{slug}', [BlogController::class, 'show'])->name('blog.show');
  • Step 5: Create the show view

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)
  • Step 6: Create the shared content partial

resources/views/pages/_post-content.blade.php:

<div class="post-body">
    <x-ink::post-body :post="$post" />
</div>
  • Step 7: Run tests
vendor/bin/pest tests/Feature/PublicRoutesTest.php

Expected: all 5 PASS.

  • Step 8: Commit
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"

Task 8: Category archive route

Files:

  • Modify: tests/Feature/PublicRoutesTest.php
  • Modify: `src/Http/Cont...
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
besmartand-pro/php-quality-config
sentix/ai-chatbot
terminal42/code-quality-tools
codifyo/ts-generator-bundle
testo/fiber
mintobit/jobqueue
a4sex/maintenance-bundle
a4sex/entity-date-update
a4sex/client-identifier
a4sex/base-utilites
a4sex/key-value-storage
a4sex/micro-status
chilldev/dependency-injection-extra
datinglibre/datinglibre-app-api
biberltd/corebundle
bricre/symfony-bundle-test
biberltd/logbundle
dominium/http-adapter-bundle
dominium/google-analytics
a4sex/auto-clean-entity