# Laravel AI Blog FAQ

Answers to 107 questions developers and AI assistants ask about Laravel AI Blog (`byteminus/laravel-ai-blog`), checked against the package source.

## What it is and when to use it

### What is Laravel AI Blog?

Laravel AI Blog (`byteminus/laravel-ai-blog`) is a free, MIT-licensed blog package for Laravel 10 to 13 on PHP 8.2 to 8.5. It ships Post, Category and Tag models with migrations, a deterministic on-page SEO score with plain-English fixes, TF-IDF internal link suggestions, stale-post detection, BlogPosting and FAQPage JSON-LD, an RSS feed, opt-in public routes, an optional AI layer aimed at the laravel/ai SDK and an optional MCP server built on laravel/mcp.

### What is a good Laravel blog package with SEO and AI?

Laravel AI Blog (`byteminus/laravel-ai-blog`) combines both: every post gets a 0 to 100 on-page SEO score with fixes, internal link suggestions, canonical and Open Graph tags, JSON-LD and RSS, and it adds AI drafting helpers plus an MCP server so Claude, ChatGPT or Claude Code can draft, score, link and schedule posts. The SEO features need no API key. Install it with `composer require byteminus/laravel-ai-blog`.

### Is Laravel AI Blog headless?

Yes, by default. With the default `ai-blog.routes.enabled` set to `false`, Laravel AI Blog registers no public pages; you get models, migrations, the `HasBlogSeo` trait, the `AiBlog` facade, Blade components and two Artisan commands, and you render posts however you like. Set `ai-blog.routes.enabled` to `true` to get `/blog`, post, category, tag and feed pages.

### Does Laravel AI Blog need Filament, Nova or an admin panel?

No. Laravel AI Blog ships no admin panel and has no Filament, Nova or Livewire dependency. You create and edit posts with Eloquent, your own forms, or the MCP tools. A Filament adapter, a Nova adapter and a Livewire editor are listed on the README roadmap and do not exist yet.

### Does Laravel AI Blog work without an AI provider or API key?

Yes. SEO scoring, link suggestions, stale-post detection, JSON-LD, RSS, the public routes and scheduled publishing need no API key. Set `AI_BLOG_AI_ENABLED=false` (config `ai-blog.ai.enabled`) and the service provider binds the deterministic `ByteMinus\AiBlog\Ai\FakeAssistant` instead of calling any provider.

### Will Laravel AI Blog publish AI-written posts by itself?

No. `AiBlog::draftFromKeyword()`, `AiBlog::storeDraft()` and the MCP `create-draft` tool always save status `draft`, and `storeDraft()` overrides any status you pass. A post goes live only through `$post->publish()` or `php artisan ai-blog:publish-scheduled` after `$post->schedule()`. The config has an `ai-blog.ai.auto_publish` key (default `false`), but no code in the current version reads it, so setting it to `true` changes nothing.

### When is Laravel AI Blog not a good fit?

Laravel AI Blog does not ship an admin editor, comments, a built-in XML sitemap, multilingual posts with hreflang, or image uploads (`featured_image` is a plain string column). If you need those out of the box you would build them yourself on top of the package models. It fits best when you want a blog inside an existing Laravel app with SEO checks and optional AI or agent access.

## Installation and requirements

### How do I install Laravel AI Blog?

Run `composer require byteminus/laravel-ai-blog`, then `php artisan vendor:publish --tag=ai-blog-migrations` and `php artisan migrate`. The service provider `ByteMinus\AiBlog\AiBlogServiceProvider` and the `AiBlog` facade alias are auto-discovered. There is no install command, and the migrations do not run until you publish them. For AI and agent features also run `composer require laravel/ai laravel/mcp`; both are optional (listed under `suggest`).

### Which Laravel and PHP versions does Laravel AI Blog support?

Laravel AI Blog supports Laravel 10, 11, 12 and 13 on PHP 8.2 to 8.5. The CI matrix runs Laravel 10 on PHP 8.2 and 8.3, Laravel 11 on 8.2 to 8.4, Laravel 12 on 8.2 to 8.5 and Laravel 13 on 8.3 to 8.5, plus a run at the lowest dependency versions. Laravel 10 and 11 are end of life and supported only for apps that have not upgraded.

### Which PHP extensions does Laravel AI Blog require?

Composer requires `ext-curl`, `ext-dom`, `ext-libxml` and `ext-mbstring`. The intl extension is optional: `UrlGuard` uses `idn_to_ascii()` when it exists to accept internationalized host names, and without it `SafeFetcher` refuses non-ASCII hosts as invalid URLs.

### What are the vendor:publish tags for Laravel AI Blog?

There are three: `ai-blog-config` publishes `config/ai-blog.php`, `ai-blog-migrations` publishes the two migrations (`create_ai_blog_tables` and `create_ai_blog_command_tables`), and `ai-blog-views` copies the views to `resources/views/vendor/ai-blog`. The tags use the short name `ai-blog` because spatie/laravel-package-tools strips the `laravel-` prefix from the package name, so `--tag=laravel-ai-blog-migrations` publishes nothing.

### Do I have to publish the Laravel AI Blog config file?

No. The defaults are merged automatically, and several settings read env vars. Publish it with `php artisan vendor:publish --tag=ai-blog-config` when you need to change models, routes, the layout, SEO thresholds, the assistant class, the style guide, command handlers or MCP middleware.

### Which environment variables does Laravel AI Blog read?

Five of its own: `AI_BLOG_AI_ENABLED` (default true), `AI_BLOG_AI_PROVIDER`, `AI_BLOG_AI_MODEL`, `AI_BLOG_MCP_ENABLED` (default false) and `AI_BLOG_SEO_DESCRIPTION`. It also uses `APP_NAME` as the default `ai-blog.seo.site_name` and `APP_URL` in the research fetcher User-Agent. Routes have no env var; set `ai-blog.routes.enabled` in the config file.

### Which database tables does Laravel AI Blog create?

With the default `ai-blog.table_prefix` of `ai_blog_`, the `create_ai_blog_tables` migration creates `ai_blog_categories`, `ai_blog_tags`, `ai_blog_posts` and `ai_blog_post_tag`, and `create_ai_blog_command_tables` creates `ai_blog_commands` and `ai_blog_audit_events` for the command bus. Both migrations and all package models read the prefix from config, so change it before you migrate.

## Upgrading from hawkforge/laravel-ai-blog

### How do I upgrade from hawkforge/laravel-ai-blog to byteminus/laravel-ai-blog?

Run `composer remove hawkforge/laravel-ai-blog` and `composer require byteminus/laravel-ai-blog`, then change every `use AiBlog\...` import to `use ByteMinus\AiBlog\...` in your code and in a published `config/ai-blog.php`, which imports the package model and assistant classes. For example, `AiBlog\Models\Post` becomes `ByteMinus\AiBlog\Models\Post`. The global facade alias is still `AiBlog`. The CHANGELOG lists this as a breaking change under Unreleased.

### Do I need new migrations after moving to byteminus/laravel-ai-blog?

No. The organization move changed the Composer name and PHP namespace only. Table names, migrations, the config file name and keys, publish tags, the `ai-blog` view namespace, the `ai-blog.*` route names and the Artisan command names are unchanged.

## Using your own Post model

### How do I use my existing Post model with Laravel AI Blog?

Add the `ByteMinus\AiBlog\Concerns\HasBlogSeo` trait to your model and set `'models' => ['post' => App\Models\Post::class]` in `config/ai-blog.php`. The trait adds `seoReport()`, `suggestedLinks()`, `structuredData()`, `ai()`, `seoTitle()`, `seoDescription()`, `bodyText()` and `targetKeyword()`.

### My columns have different names. How do I map them for HasBlogSeo?

Override the field methods the trait defines: `getTitleField()` (default `title`), `getBodyField()` (`body`), `getSlugField()` (`slug`), `getSeoTitleField()` (`seo_title`), `getSeoDescriptionField()` (`seo_description`) and `getTargetKeywordField()` (`target_keyword`). For example, `public function getBodyField(): string { return 'content'; }`.

### Is the HasBlogSeo trait enough for every Laravel AI Blog feature?

No. The trait covers scoring, link suggestions and JSON-LD. The public routes, `ai-blog:publish-scheduled`, `AiBlog::stale()`, `AiBlog::brief()`, the MCP tools and `ai-blog:audit` also expect the package Post behavior: the `published`, `dueForPublishing` and `stale` scopes, a `publish()` method, a `tags` relation and columns such as `title`, `slug`, `body`, `status`, `published_at`, `category_id`, `seo_score`, `ai_assistance` and `meta`. The config says any replacement must extend or behave like the package model.

### Can I extend the Laravel AI Blog Post model instead?

Yes, and it is the simplest way to keep every feature working: `class Post extends \ByteMinus\AiBlog\Models\Post`, then point `ai-blog.models.post` at it. The table still comes from `getTable()`, which returns the prefix plus `posts` (`ai_blog_posts` by default), unless you override it. Swap the Category and Tag models the same way with `ai-blog.models.category` and `ai-blog.models.tag`.

## SEO scoring

### How do I get an SEO score for a post?

Call `$post->seoReport()` or `AiBlog::score($post)`. Both return a `ByteMinus\AiBlog\Seo\SeoReport` with `score` (an integer from 0 to 100), `checks`, `grade()`, `fixes()`, `failures()` and `toArray()`. The analysis is deterministic and needs no API key.

### Which on-page checks does the Laravel AI Blog SEO score run?

Seven checks always run, with weights: `title_length` 10, `description_length` 10, `word_count` 10, `has_h2` (at least one H2) 8, `image_alt` (every image has alt text) 6, `links` (at least two links) 8 and `readability` (Flesch reading ease of 50 or more) 6. The keyword checks are added on top. The score is the weight of passed checks divided by the total weight, times 100, rounded.

### Which target keyword checks does Laravel AI Blog run?

When `target_keyword` is set: `keyword_set` 4, `keyword_in_title` 10, `keyword_in_description` 6, `keyword_in_slug` 6 (the keyword lowercased with spaces turned into hyphens), `keyword_early` 8 (within the first 100 words) and `keyword_in_h2` 6. Matching is case-insensitive. When no keyword is set, these are replaced by one failing `keyword_set` check worth 10.

### Which SEO thresholds can I configure?

Under `ai-blog.seo`: `title_max` (default 60 characters), `description_min` (120), `description_max` (160) and `min_words` (600). The two-link minimum, the readability target of 50 and the check weights are fixed in `SeoAnalyzer` and have no config keys.

### How are SEO grades calculated?

`$report->grade()` on a `SeoReport` returns A for a score of 90 or more, B for 75 or more, C for 60 or more, D for 40 or more, and F below 40.

### Why is my SEO score stuck at 85 or lower?

Most likely the post has no `target_keyword`. Without one, the total weight is 68 and the failing `keyword_set` check costs 10, so the best possible score is 58 of 68, which rounds to 85 (grade B). Set `target_keyword`, or run `AiBlog::enrichSeo($post)` to have the assistant pick one.

### Which title and description does the SEO score check?

The title checks use `seoTitle()`, which is `seo_title` when set and otherwise `title`. The description checks use `seo_description` only; the `excerpt` does not count toward the score, although the meta tag component falls back to the excerpt.

### Is the seo_score column updated automatically on save?

No. There is no save hook. `seo_score` is written by `AiBlog::storeDraft()`, `AiBlog::draftFromKeyword()`, `AiBlog::enrichSeo()` when it changes something, `php artisan ai-blog:audit`, and the MCP `create-draft`, `update-post` and `score-post` tools. Call `$post->seoReport()` for a live score.

### What does php artisan ai-blog:audit do?

It scores the newest 50 posts of any status (`--limit=` changes the count), saves each `seo_score`, and prints a table with ID, title, score and grade, the top three fixes and up to three internal link suggestions. `--fix` first runs `AiBlog::enrichSeo()` on each post to fill a missing keyword and meta description with the AI assistant. `--stale` audits only stale posts instead.

## Internal links and stale posts

### How does Laravel AI Blog suggest internal links?

Call `$post->suggestedLinks($max)` or `AiBlog::suggestLinks($post, $max)`. It compares the post's title and body with other published posts using TF-IDF cosine similarity and returns a collection of arrays with `post`, `score` (0 to 1, three decimals) and `anchor`. The anchor is the longest run of two to five body words that match the target title, else the target title. It uses word statistics, not embeddings, and needs no API key.

### How do I configure internal link suggestions?

Use `ai-blog.seo.internal_links.max_suggestions` (default 5) for the number returned and `ai-blog.seo.internal_links.min_score` (default 0.15) for the minimum similarity. Passing `$max` to `suggestedLinks()` overrides the count for one call. Post pages show up to three related posts.

### Does Laravel AI Blog insert internal links into posts automatically?

No. It only suggests them, and it skips posts the body already links to by comparing slugs in Markdown links and `href` attributes. The MCP `suggest-links` tool also returns a ready Markdown link such as `[anchor](/blog/slug)` that an agent can insert with `update-post`.

### How does Laravel AI Blog detect stale posts?

`AiBlog::stale($days)` and the `Post::stale($days)` scope return published posts that were never reviewed and were published before the cutoff, or were last reviewed before it. The cutoff defaults to `ai-blog.seo.stale_after_days` (365). Call `$post->markReviewed()` to set `last_reviewed_at` to now, and run `php artisan ai-blog:audit --stale` to list them.

### How do I get AI suggestions to refresh an old post?

Call `$post->ai()->refreshSuggestions($post->title, $post->body, $post->published_at)`, which returns an array of specific update suggestions as strings. The package does not store them or change the post; apply them yourself and then call `$post->markReviewed()`.

## Blog routes, Blade components, meta tags, JSON-LD and RSS

### How do I enable the public blog pages?

In `config/ai-blog.php` set `'routes' => ['enabled' => true, 'prefix' => 'blog', 'middleware' => ['web'], 'layout' => 'layouts.app']`. That registers `/blog` (`ai-blog.index`), `/blog/feed` (`ai-blog.feed`), `/blog/category/{slug}` (`ai-blog.category`), `/blog/tag/{slug}` (`ai-blog.tag`) and `/blog/{slug}` (`ai-blog.show`). Index pages show 12 posts per page.

### What does my layout need for the Laravel AI Blog pages?

The pages extend the view named in `ai-blog.routes.layout` (default `layouts.app`), which must contain `@yield('title')`, `@yield('content')` and `@stack('head')`. The pages push their meta tags and JSON-LD into the `head` stack.

### Which Blade components does Laravel AI Blog provide?

Four anonymous components: `<x-ai-blog::meta-tags :post="$post" />`, `<x-ai-blog::structured-data :post="$post" />`, `<x-ai-blog::post-body :post="$post" />` and `<x-ai-blog::index-meta :posts="$posts" :heading="$heading" />`, where `$posts` is a paginator. `post-body` renders the Markdown body and strips unsafe HTML. They work in your own views even when the package routes are disabled; publish them with `--tag=ai-blog-views` to edit them.

### Which meta tags does a Laravel AI Blog post page send?

A description (`seo_description`, else the excerpt, omitted when both are empty), a `robots` tag allowing large image previews, a canonical link, an RSS alternate link, `og:type`, `og:site_name`, `og:title`, `og:description`, `og:url`, `og:image` and `og:image:alt`, `article:published_time`, `article:modified_time`, and Twitter tags with `summary_large_image` when a featured image exists. Image URLs are made absolute.

### How does Laravel AI Blog set the canonical URL?

The canonical URL is the post's `canonical_url` column when set, otherwise `route('ai-blog.show', $slug)`. If the routes are disabled and `canonical_url` is empty, no canonical tag is sent. Index, category and tag pages use the current page URL as canonical and add `prev` and `next` links.

### What JSON-LD does Laravel AI Blog output?

`<x-ai-blog::structured-data>` outputs `BlogPosting` with headline, description, wordCount, datePublished, dateModified, an absolute image, keywords (the target keyword), url, mainEntityOfPage, a publisher `Organization` named after `ai-blog.seo.site_name`, and a `Person` author when one is set. Empty fields are left out. `$post->structuredData()` returns the same array.

### How do I get FAQPage structured data from a post?

Write a Markdown H2 that ends in a question mark, such as `## Is it free?`, followed by an answer paragraph. `StructuredData::faqFromBody()` detects these pairs and the structured-data component adds a second `FAQPage` JSON-LD block. HTML `<h2>` headings are not detected for FAQ.

### What does the Laravel AI Blog RSS feed contain?

`/blog/feed` returns RSS 2.0 as `application/rss+xml` with the 20 newest published posts. The channel has the site name, `ai-blog.seo.description` (or the site name plus blog), the app locale as language and a last build date; each item has title, link, a permalink GUID, pubDate and the excerpt or SEO description. There is no Atom or JSON Feed output.

### How do I set the blog name and description for SEO?

Set `ai-blog.seo.site_name`, which defaults to `APP_NAME` and then to `Blog`, and `ai-blog.seo.description` or the `AI_BLOG_SEO_DESCRIPTION` env var. Without a description, index pages fall back to a generated line based on the site name, and the feed uses the site name followed by blog.

### Does Laravel AI Blog generate an XML sitemap?

No. A built-in XML sitemap is on the README roadmap. `spatie/laravel-sitemap` is listed under `suggest` in composer.json, but no package code integrates with it. Build the sitemap yourself, for example from `Post::published()` and `route('ai-blog.show', $post->slug)`.

## AI features

### Which AI features does Laravel AI Blog have?

On the `AiBlog` facade: `brief($keyword)`, `draftFromKeyword($keyword, $attributes)` and `enrichSeo($post)`. Through `AiBlog::assistant()` or `$post->ai()` you get the `Assistant` methods: `suggestTitles()`, `writeMetaDescription()`, `extractKeyword()`, `brief()`, `draft()`, `rewrite()`, `proposeAnchor()`, `refreshSuggestions()` and `translate()`. None of them publish.

### How does AiBlog::draftFromKeyword() work?

It asks the assistant for a brief (intent, audience, working title, outline, questions, related terms), passing up to 50 published post titles so it can avoid duplicates, then writes a Markdown draft from the brief. The post is saved with status `draft`, `ai_assistance` set to `drafted`, the brief in `meta.brief`, any image prompt in `meta.image_prompt`, and its `seo_score`.

### What does AiBlog::enrichSeo() do?

It fills only what is missing: a `target_keyword` from `extractKeyword()` when none is set, and a `seo_description` from `writeMetaDescription()` when it is empty. It sets `ai_assistance` to `assisted` if it was null, updates `seo_score`, and returns an array of the fields it changed. It never changes the title or body.

### Which AI providers does Laravel AI Blog support?

The package does not list providers itself; it delegates to the laravel/ai SDK. The default `LaravelAiAssistant` reads `ai-blog.ai.provider` (`AI_BLOG_AI_PROVIDER`) and `ai-blog.ai.model` (`AI_BLOG_AI_MODEL`), and null values mean the SDK defaults. Any other client works by binding your own class through `ai-blog.ai.assistant`; see the next question about laravel/ai 1.x compatibility.

### Does the default laravel/ai integration work with laravel/ai 1.x?

Yes. `LaravelAiAssistant::complete()` sends each request as a one-off `Laravel\Ai\AnonymousAgent`, with the system prompt as its instructions and `ai-blog.ai.provider` and `ai-blog.ai.model` passed to `prompt()`. It is tested against laravel/ai 1.2 with `AnonymousAgent::fake()`. laravel/ai itself needs PHP 8.3 and Laravel 12 or newer. Releases up to 0.1.1 looked for a `Laravel\Ai\Facades\Ai` class that laravel/ai does not have, so upgrade if AI calls fail.

### How do I plug in my own AI client?

Implement `ByteMinus\AiBlog\Ai\Contracts\Assistant` and set `ai-blog.ai.assistant` to your class, or extend `ByteMinus\AiBlog\Ai\LaravelAiAssistant` and override `complete(string $system, string $prompt): string`, the only method that talks to an SDK. Every prompt comes from `ByteMinus\AiBlog\Ai\Prompts`, and the JSON parser tolerates code fences and preamble.

### What happens if laravel/ai is not installed?

Everything except AI calls keeps working. With `ai-blog.ai.enabled` true (the default), any AI call through the default assistant throws a `RuntimeException`: "Laravel AI Blog's AI features need the laravel/ai SDK: composer require laravel/ai. Or set ai-blog.ai.enabled=false to run without AI." That includes `draftFromKeyword()`, `enrichSeo()`, `ai-blog:audit --fix` and the MCP `draft-from-keyword` tool.

### What does [verify: ...] mean in a Laravel AI Blog draft?

The system prompt tells the model never to invent facts, statistics or quotes and to mark anything it is unsure of as `[verify: ...]` inside the text. The package does not scan for or block on these markers, so search a draft for `[verify:` and check each claim before you publish.

### What is the ai_assistance column?

`ai_assistance` records how much AI was involved; the migration documents the values `none`, `assisted` and `drafted`. The package sets `drafted` for `draftFromKeyword()`, `storeDraft()` and MCP `create-draft`, and `assisted` for `enrichSeo()` and MCP `update-post` when the column was empty. It never sets `none` and does not show the value on public pages.

### How do I set a house style for the AI?

Set `ai-blog.ai.style_guide` (default: "Write clearly and concretely. Short paragraphs. No filler."). It is added to the system prompt of every AI call and exposed to MCP clients as the resource `ai-blog://style-guide`.

## MCP server for Claude, ChatGPT and agents

### How do I enable the Laravel AI Blog MCP server?

Run `composer require laravel/mcp` and set `AI_BLOG_MCP_ENABLED=true` (config `ai-blog.mcp.enabled`). The service provider then registers `ByteMinus\AiBlog\Mcp\BlogServer` with `Mcp::web()` at `ai-blog.mcp.path` (default `/mcp/ai-blog`) behind `ai-blog.mcp.middleware` (default `['auth:sanctum']`). Nothing is registered when laravel/mcp is missing.

### Which MCP tools does Laravel AI Blog provide?

Eight tools: `list-posts`, `get-post`, `create-draft`, `update-post`, `draft-from-keyword`, `score-post`, `suggest-links` and `schedule-post`, plus one resource, `ai-blog://style-guide`. There is no publish or delete tool.

### Are the MCP tool names list_posts or list-posts?

Clients see kebab-case names such as `list-posts` and `schedule-post`. The tool classes set no explicit name, so laravel/mcp derives it from the class name with `Str::kebab()`. The README and some tool descriptions write snake_case (`list_posts`, `update_post`), which refers to the same tools.

### What parameters do the read-only MCP tools take?

`list-posts` takes optional `status` (draft, scheduled, published or archived), `search` (matches title and body, up to 200 characters) and `limit` (1 to 100, default 20), newest first. `get-post` takes `id`. `score-post` takes `id` and returns the score, grade, checks and fixes. `suggest-links` takes `id` and optional `max` (1 to 20, default 5).

### What parameters do the MCP write tools take?

`create-draft` needs `title` and a Markdown `body`, with optional `excerpt`, `target_keyword` and `seo_description`. `update-post` needs `id` and changes only the fields you pass among `title`, `body`, `excerpt`, `target_keyword`, `seo_title` and `seo_description`; it cannot change status. `draft-from-keyword` needs `keyword`. `schedule-post` needs `id` and `at`, an ISO 8601 time in the future.

### Why is there no publish tool in the Laravel AI Blog MCP server?

It is deliberate: agents draft, score, link and schedule, and a person or the scheduler publishes. The server instructions tell agents never to claim a post is live. Note that `schedule-post` lets an agent schedule a post, and `ai-blog:publish-scheduled` publishes it once its time passes, so if you want a human to approve every post, restrict who can reach the MCP route.

### How do I connect Claude Code to the Laravel AI Blog MCP server?

Create a Sanctum token for a user (with the default `auth:sanctum` middleware), then run `claude mcp add --transport http my-blog https://example.com/mcp/ai-blog --header "Authorization: Bearer <token>"`. Use your own domain and the path from `ai-blog.mcp.path`.

### How do I connect Claude.ai or ChatGPT to the Laravel AI Blog MCP server?

Their custom connectors need OAuth. The README recommends laravel/mcp's OAuth support, `Mcp::oauthRoutes()`, with Laravel Passport, and then setting `ai-blog.mcp.middleware` to match that auth setup. Laravel AI Blog does not register OAuth routes itself.

### Who is allowed to use the Laravel AI Blog MCP tools?

Anyone who passes the route middleware in `ai-blog.mcp.middleware`. The tools do not check the `manageAiBlog` gate and do not go through the command bus. To limit access to blog managers, add Laravel's `can` middleware: `'middleware' => ['auth:sanctum', 'can:manageAiBlog']`.

## Posts, publishing and scheduling

### What fields does a Laravel AI Blog post have?

The `ai_blog_posts` table has `title`, `slug` (unique), `excerpt`, `body`, `status`, `published_at`, `scheduled_for`, `last_reviewed_at`, `seo_title`, `seo_description` (up to 320 characters), `target_keyword`, `canonical_url`, `featured_image`, `featured_image_alt`, `seo_score`, `ai_assistance`, `category_id`, a nullable `author` morph, `meta` (JSON), soft deletes and timestamps.

### What post statuses does Laravel AI Blog support?

The `ByteMinus\AiBlog\Enums\PostStatus` enum has `draft`, `scheduled`, `published` and `archived`. New posts default to `draft`. There is no `archive()` or `unpublish()` method; set `status` yourself for those. The `published()` scope only returns posts with status `published` and a `published_at` that is not in the future.

### How do I publish a post in Laravel AI Blog?

Call `$post->publish()`. It sets status `published`, keeps an existing `published_at` or sets it to now, clears `scheduled_for`, saves, and fires `ByteMinus\AiBlog\Events\PostPublished`.

### How do I schedule a post?

Call `$post->schedule($dateTime)` with any `DateTimeInterface`. It sets status `scheduled` and `scheduled_for`, saves, and fires `ByteMinus\AiBlog\Events\PostScheduled`. Then `php artisan ai-blog:publish-scheduled` publishes every scheduled post whose `scheduled_for` has passed, calling `publish()` on each. `schedule()` does not reject past times; the MCP `schedule-post` tool does.

### How do I run ai-blog:publish-scheduled automatically?

Add `Schedule::command('ai-blog:publish-scheduled')->everyMinute();` to `routes/console.php` (on Laravel 10, add the command to the schedule in your console kernel) and make sure Laravel's scheduler runs from cron with `php artisan schedule:run`.

### Which events does Laravel AI Blog fire?

Two: `ByteMinus\AiBlog\Events\PostPublished` from `publish()` and `ByteMinus\AiBlog\Events\PostScheduled` from `schedule()`. Each has a public `$post` property. No events fire for drafts, updates, AI calls or SEO scoring.

## Safe fetching for research

### What is SafeFetcher in Laravel AI Blog?

`ByteMinus\AiBlog\Fetching\SafeFetcher` fetches one public web page, feed or JSON document for research and returns a `FetchedDocument` with the requested and final URL, status, content type, body, a SHA-256 of the body, the fetch time and any redirects. Call `app(SafeFetcher::class)->fetch($url)`. The package does not call it on its own; you use it in your own job or command.

### Which rules does SafeFetcher enforce?

On the first URL and every redirect (at most 5): http or https only, no credentials, ports 80, 443, 8080 or 8443, no internal host names such as `localhost` or `.internal`, and no shorthand numeric hosts. It resolves the host itself, requires every address to be public, pins the connection to the checked address against DNS rebinding, obeys robots.txt, ignores proxies and cookies, and keeps only HTML, XML, feeds, JSON and plain text.

### Why does SafeFetcher refuse to run inside a web request?

A fetch can use its whole time budget, and a page a person is waiting on should not wait on another server. When `App::runningInConsole()` is false, `fetch()` throws `FetchRefused` with reason `web_request`. Call it from a queued job processed by a queue worker or from an Artisan command.

### What are the SafeFetcher limits and how do I change them?

Defaults: 2 MiB body (checked on the wire and after decompression), 30 seconds total including redirects and robots.txt, 5 seconds to connect, and robots.txt cached for 86400 seconds. They are constructor arguments (`maxBytes`, `totalSeconds`, `connectSeconds`, `robotsCacheSeconds`), not config keys, so change them by binding `SafeFetcher` in a service provider with your own values.

### How does SafeFetcher handle robots.txt?

It reads `/robots.txt` with the product token `AiBlogBot`, applies the groups for that token or else the `*` groups, and lets the longest matching rule win. A missing robots.txt (any 4xx except 429) allows everything; a 5xx, 429 or unreachable robots.txt refuses the whole site with reason `robots_unavailable`, which is marked retryable and is not cached. The User-Agent is `AiBlogBot/1.0` plus your `APP_URL`.

### Which feed formats does FeedParser read?

`ByteMinus\AiBlog\Fetching\Feeds\FeedParser` reads RSS 2.0, RSS 1.0 (RDF), Atom and JSON Feed into a `ParsedFeed` with `format`, `items`, `title`, `homePageUrl` and `truncated`. Use `parseDocument($document)` or `parse($body, $feedUrl)`. It keeps up to 100 items by default, refuses documents over 5 MiB, and refuses any XML with a DOCTYPE or ENTITY declaration by throwing `FeedUnreadable`.

### What does TextExtractor do?

`app(\ByteMinus\AiBlog\Fetching\TextExtractor::class)->extract($document)` returns an `ExtractedText` with readable text (up to 20,000 characters by default), title, author, published date and a `truncated` flag. It reads text from `article`, else `main`, else the body, after removing scripts, forms, navigation and anything hidden with `hidden`, `aria-hidden` or inline `display:none`, and it strips control and invisible characters.

### What is UntrustedText and when should I use it?

`ByteMinus\AiBlog\Fetching\UntrustedText` wraps outside text for a prompt between BEGIN and END markers that carry a random nonce, with a statement that the content is data, not instructions. Get one with `$extracted->untrusted()`. It lowers prompt injection risk but does not remove it, so hand fetched content to a model only this way.

### Which HTML parser does TextExtractor use?

On PHP 8.4 and later it uses PHP's spec-compliant HTML5 parser, `Dom\HTMLDocument`. On older PHP it falls back to libxml2's HTML parser, which gives the same results on the tested pages but stops at about 256 levels of nesting and returns no text for a page nested deeper. `new TextExtractor(useLegacyParser: true)` forces the fallback.

## Command bus, permissions and audit trail

### What is the Laravel AI Blog command bus?

`app(\ByteMinus\AiBlog\Bus\CommandBus::class)->dispatch(Actor $actor, CommandRequest $request)` runs a registered command handler after checking that the command exists, the idempotency key is well formed, and the actor holds the ability. It validates params, stores the command in `ai_blog_commands`, writes an audit event to `ai_blog_audit_events`, and returns a `CommandReceipt` with `result()` and `replayed`.

### Does Laravel AI Blog ship any command bus handlers?

No. `ai-blog.commands.handlers` is empty by default, and in the current code the MCP tools and the Artisan commands call the models directly instead of the bus. The bus is for handlers you register, so a dashboard, a job, an Artisan command and an agent can share the same ability, idempotency and audit rules.

### How do I write a command handler?

Extend `ByteMinus\AiBlog\Bus\CommandHandler`, implement `ability()` (return null to allow only people and jobs) and `handle(CommandContext $context): CommandOutcome`, and optionally `rules()` and `preview()`. Register it as `'commands' => ['handlers' => ['post.rename' => RenamePost::class]]`. Names must be lowercase dot-separated segments, such as `post.rename`, up to 64 characters.

### What is the manageAiBlog gate?

It decides which people may run commands. The package defines `manageAiBlog` to deny everyone unless you define it yourself, for example `Gate::define('manageAiBlog', fn ($user) => $user->is_admin);` in a service provider. `Actor::user($user)` throws an `AuthorizationException` with "This account may not manage the blog." when the gate denies.

### What actor types does the command bus support?

`Actor::user($user)` is a person who passed the `manageAiBlog` gate and may run every command. `Actor::job('name')` is a background job that may run every command. `Actor::credential($id, $abilities)` is a connected credential limited to the listed abilities and refused for commands whose ability is null. `Actor::brain(...)` may run only the commands listed with it.

### How does idempotency work in the command bus?

Every `CommandRequest` needs a key of 8 to 120 characters from letters, digits and `. _ : -`. The same key from the same actor with the same name, params and dry-run flag replays the stored result and sets `$receipt->replayed` to true. Any other reuse throws `CommandRefused` with reason `conflict` (HTTP 409). A refused command gives its key up for the next request.

### How do I do a dry run with the command bus?

Pass `dryRun: true` to `new CommandRequest(...)`. The bus runs every check, then calls the handler's `preview()` instead of `handle()`. The default preview returns `dry_run`, `would_run` and `checks` without changing anything. The dry run is still stored as a command and audited with outcome `dry_run`.

### What does the Laravel AI Blog audit trail record?

Each command writes one row to `ai_blog_audit_events` with actor type and id, action (the command name), subject type and id, old and new hashes, a reason for refusals, meta such as the command id and outcome, and the IP. The `AuditEvent` model is append-only: updating or deleting a row throws a `LogicException`. Params whose key ends in `_token` are stored only as a SHA-256 hash.

## Testing

### How do I test an app that uses Laravel AI Blog without calling an AI provider?

Set `AI_BLOG_AI_ENABLED=false` in `phpunit.xml`, or set `ai-blog.ai.enabled` to false before anything resolves the assistant. The service provider then binds `FakeAssistant`, and `app(\ByteMinus\AiBlog\Ai\Contracts\Assistant::class)->calls` lists the methods your code called. The package's own tests use the same setting.

### How do I test publishing and scheduling?

Use `Event::fake([PostPublished::class])`, call `$post->publish()`, and assert the event was dispatched. For scheduling, call `$post->schedule(now()->subMinute())`, run `$this->artisan('ai-blog:publish-scheduled')->assertSuccessful()`, and check the post status. Slug and status defaults are applied in `performInsert()`, so they still work with events faked.

### How do I test code that uses SafeFetcher?

PHPUnit runs in the console, so the web request refusal does not apply. Use `Http::fake()` for responses, including `robots.txt`, and bind `ByteMinus\AiBlog\Fetching\HostResolver` to a fake that returns public IP addresses so no real DNS lookup happens. The package's own tests do this with a test-only `FakeHostResolver` that is not shipped.

## Troubleshooting

### Why do I get "Laravel AI Blog's AI features need the laravel/ai SDK"?

The default `LaravelAiAssistant` throws it when `Laravel\Ai\AnonymousAgent` does not exist, which means laravel/ai is not installed. Run `composer require laravel/ai` (it needs PHP 8.3 and Laravel 12 or newer), set `AI_BLOG_AI_ENABLED=false` to run without AI, or bind your own `Assistant` through `ai-blog.ai.assistant`. On releases up to 0.1.1 this error appeared even with laravel/ai installed; upgrade the package.

### Why does the MCP route return 401?

The route middleware, `auth:sanctum` by default, rejected the request. Send `Authorization: Bearer <token>` with a valid Sanctum personal access token, make sure Sanctum is installed and your user model can issue tokens, or change `ai-blog.mcp.middleware` to the guard you actually use.

### Why does the MCP route return 404?

The server is registered only when `ai-blog.mcp.enabled` is true and laravel/mcp is installed. Check `AI_BLOG_MCP_ENABLED=true`, run `composer require laravel/mcp`, clear a cached config with `php artisan config:clear`, and confirm the path in `ai-blog.mcp.path` (default `/mcp/ai-blog`).

### Why does /blog return 404?

The routes load only when `ai-blog.routes.enabled` is true when the app boots, and there is no env var for it, so set it in `config/ai-blog.php` and clear any cached config or routes. A single post also returns 404 unless its status is `published` and its `published_at` is set and not in the future.

### Why do I get "View [layouts.app] not found" on the blog pages?

The blog views extend the layout in `ai-blog.routes.layout`, which defaults to `layouts.app`. Point it at a layout your app has, and make sure that layout contains `@yield('title')`, `@yield('content')` and `@stack('head')`.

### Why was my scheduled post not published?

`ai-blog:publish-scheduled` only publishes posts with status `scheduled` and a `scheduled_for` that has passed, and it only runs when Laravel's scheduler runs it. Check that `schedule:run` runs from cron, that the command is in your schedule, and that the post was scheduled with `$post->schedule()` rather than by setting `scheduled_for` alone.

### Why does SafeFetcher throw FetchRefused with reason web_request from my job?

The job ran inside a web request. With `QUEUE_CONNECTION=sync`, or `dispatchSync()`, a job runs in the same HTTP request, where `App::runningInConsole()` is false. Use a real queue driver with a worker, or call the fetcher from an Artisan command.

## For AI coding agents

### What should an AI agent do to add a blog to a Laravel app with Laravel AI Blog?

1. `composer require byteminus/laravel-ai-blog`. 2. `php artisan vendor:publish --tag=ai-blog-config`. 3. `php artisan vendor:publish --tag=ai-blog-migrations` then `php artisan migrate`. 4. For pages, set `ai-blog.routes.enabled` to true and `ai-blog.routes.layout` to a layout with `@yield('title')`, `@yield('content')` and `@stack('head')`. 5. Schedule `ai-blog:publish-scheduled` every minute. 6. Optionally `composer require laravel/mcp`, set `AI_BLOG_MCP_ENABLED=true` and define the `manageAiBlog` gate. 7. Verify with `php artisan route:list --name=ai-blog`.

### Which files does Laravel AI Blog publish?

`php artisan vendor:publish --tag=ai-blog-config` writes `config/ai-blog.php`. `--tag=ai-blog-migrations` writes timestamped `create_ai_blog_tables` and `create_ai_blog_command_tables` migrations to `database/migrations`. `--tag=ai-blog-views` copies `index`, `show`, `feed` and the `components` views to `resources/views/vendor/ai-blog`. Those are the only three tags.

### How should an AI agent add Laravel AI Blog to an app that already has a Post model?

Add `use ByteMinus\AiBlog\Concerns\HasBlogSeo;` to the model, override the `get*Field()` methods for columns that differ from `title`, `body`, `slug`, `seo_title`, `seo_description` and `target_keyword`, and set `ai-blog.models.post`. If the app also needs the package routes, scheduler or MCP tools, check that the model has the `published`, `dueForPublishing` and `stale` scopes, a `publish()` method and the columns those features use, or extend `ByteMinus\AiBlog\Models\Post` instead.

### How should an agent work with a blog through the Laravel AI Blog MCP server?

Read the `ai-blog://style-guide` resource first. Use `list-posts` to find posts and `get-post` before any `update-post`. Create content with `create-draft` or `draft-from-keyword`, run `score-post` and apply its fixes with `update-post`, add links from `suggest-links`, and call `schedule-post` only when a person asked for a publish time. Never tell the user a post is live.

### What should an AI agent not assume about Laravel AI Blog?

Do not assume a sitemap, admin panel, comments, hreflang or image uploads; none exist. The migration publish tag is `ai-blog-migrations`, not `laravel-ai-blog-migrations`. MCP tool names are kebab-case, such as `list-posts`. `ai-blog.ai.auto_publish` is not read by any code. The MCP tools do not check the `manageAiBlog` gate.

### How can an agent verify that Laravel AI Blog is installed correctly?

Run `php artisan list ai-blog` to see `ai-blog:audit` and `ai-blog:publish-scheduled`, check that the `ai_blog_posts` table exists after migrating, and run `php artisan ai-blog:audit`, which prints "No posts to audit." on an empty blog. With routes enabled, `php artisan route:list --name=ai-blog` should list five routes.

## License, support, security and roadmap

### Is Laravel AI Blog free?

Yes. Laravel AI Blog is released under the MIT license with no paid tier, no Nova license requirement and no SaaS dependency.

### Where do I report a bug or ask for help with Laravel AI Blog?

Open an issue at https://github.com/byteminus/laravel-ai-blog/issues. For a security problem, do not open a public issue; report it privately instead.

### How do I report a security vulnerability in Laravel AI Blog?

Use GitHub private reporting: open the Security tab of https://github.com/byteminus/laravel-ai-blog and choose Report a vulnerability, with what you found, how to reproduce it and the affected version. Security fixes go to the latest release. Reports about SSRF or DNS rebinding bypasses, prompt injection paths, command bus authorization or idempotency flaws and audit gaps are especially welcome.

### How do I contribute to Laravel AI Blog?

Clone the repository, run `composer install` and `composer test`. Before a PR, make sure tests pass, `composer format` (Pint) and `composer analyse` (PHPStan) are clean, new behavior has a test, and the PR contains one change. Keep the principles: AI actions produce drafts, scoring works without an API key, and no admin panel is required.

### What is on the Laravel AI Blog roadmap?

Not built yet: a built-in XML sitemap, semantic linking with embeddings, topic intelligence (content gaps and clusters), Filament and Nova adapters, a Livewire editor, multilingual posts with hreflang, and scheduled pipelines such as two drafts a week from a keyword list.
