Kemp.rest / Examples / Code / Page builder
KEMP.REST / EXAMPLES / CODE
Service area page builder
Written by Kevin Kemp in PHP and Laravel for AnswerPins. ChatGPT writes the page. JSON-LD schema is attached from the business record. The OpenAI key stays in config.
View the codeAnswerPins builds one service-area page for one service in one town, then places it on the owner’s WordPress. This is the AI SEO page builder: the words, the WordPress blocks, the schema, and the publish step that also refreshes llms.txt.
Type of code
Laravel services and one queued job. A controller dispatches the job. The screen tells the owner publishing takes a few seconds.
Technology
PHP, Laravel, Eloquent, the OpenAI Responses API with a strict JSON Schema, JSON-LD, CSS for the page width, WordPress block markup, and the WordPress REST API. The model name and the API key come from config. WordPress signs the owner in with an application password stored on the business, not in these files.
What it does
- ChatGPT receives the business, the service, the town, the owner’s tone, a writing sample, and the questions customers ask. It must return a title, a two-sentence intro, two to four paragraphs, and three to five FAQs. The title stays under 60 characters, in the shape “Service in Town”.
- The draft may use only the facts it was given. Prices, years, licences, reviews, streets, and landmarks stay out of the model’s job. The owner adds local proof separately, and that proof is what the page is allowed to publish.
- The renderer writes native WordPress blocks: paragraph, heading, image, quote, and list, inside a group the theme can style. The same parts can be plain HTML for a preview or a site that is not WordPress.
- Publish has three modes. Update writes new words onto the existing page and keeps its address. Replace takes over that address and moves the old page to a draft. New adds a page and copies the template and parent the owner picked as the model.
- Before an update, the old title and content are stored. After the save, the live URL is fetched with a cache-busting query, up to three times, three seconds apart. The job allows 150 seconds and a single try. If the marker class is missing, the previous page is put back.
- Elementor, Divi, Beaver Builder, WPBakery, Bricks, Oxygen, and Thrive Architect are recognized from the live HTML. A page built in one of those is published by taking over its address.
Schema
Each page ends with application/ld+json. The @graph is a Service whose provider is the LocalBusiness, plus an FAQPage when the page has questions. Name, URL, phone, address, and the town in areaServed come from the business record. The same graph is written into the WordPress block version and the plain HTML version. The schema generator and validator on presscare.com are the hand tools for the same kind of JSON-LD.
Problem it solved
A local business needed a real page for each service and town, editable in WordPress, with schema search and answer engines can read, and a publish path that restores the old page when the theme does not show the new one.
Code
Back to summary · AnswerPins · page builder
// —— app/Services/PageDrafter.php ——
<?php
namespace App\Services;
use App\Models\Page;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Writes the words of a Service Area Page with ChatGPT, in the owner's tone, from the master record only.
* Facts, proof and structured data are not written by AI: they come from the master record and the owner.
*/
class PageDrafter
{
public function draft(Page $page): void
{
$page->loadMissing('business.facts', 'business.questions', 'service', 'town');
$b = $page->business;
$service = $page->service->name;
$town = $page->town->name.($b->state() ? ', '.$b->state() : '');
$questions = $b->questions->where('service_id', $page->service_id)->where('town_id', $page->town_id)->pluck('text')->values()->all();
$facts = collect(\App\Models\Business::FACT_KEYS)->mapWithKeys(fn ($k) => [$k => $b->fact($k)])->filter()->all();
$brief = [
'business' => $b->name, 'website' => $b->domain, 'what_they_do' => $b->description,
'service' => $service, 'town' => $town, 'facts' => $facts,
'tone' => $b->tone ?: 'Direct and expert', 'writing_sample' => $b->writing_sample ?: null,
'questions_customers_ask' => $questions,
];
$instructions = <<<'TXT'
You write a Service Area Page for a local service business's own website, for one service in one town.
Rules:
- Write as the business ("we"), in the given tone. If a writing sample is given, match its voice.
- intro: exactly two sentences. Say what the business does, the service and the town, and how to book (use the phone or booking link from facts if present).
- job: 2 to 4 short paragraphs on what the job involves, how the business does it, and what the customer can expect. Plain words, no hype, no superlatives like "best" or "top-rated".
- faqs: 3 to 5 short questions and answers a customer in that town would ask, covering the questions_customers_ask. Answers 1 to 3 sentences.
- Use ONLY facts given. Never invent prices, years, licences, reviews, guarantees, jobs, streets, landmarks or customers. If a fact is missing, leave it out.
- Do not mention other towns. Do not write local proof (jobs done, streets, customer quotes); the owner adds that separately.
- title: under 60 characters, "{Service} in {Town}" style. Do not add the business name; the website adds it.
TXT;
$res = Http::withToken(config('answerpins.engines.chatgpt.key'))->timeout(120)->post('https://api.openai.com/v1/responses', [
'model' => config('answerpins.pages.model'),
'instructions' => $instructions,
'input' => json_encode($brief, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
'text' => ['format' => ['type' => 'json_schema', 'name' => 'service_area_page', 'strict' => true, 'schema' => [
'type' => 'object', 'additionalProperties' => false, 'required' => ['title', 'intro', 'job', 'faqs'],
'properties' => [
'title' => ['type' => 'string'],
'intro' => ['type' => 'string'],
'job' => ['type' => 'array', 'items' => ['type' => 'string']],
'faqs' => ['type' => 'array', 'items' => ['type' => 'object', 'additionalProperties' => false, 'required' => ['q', 'a'],
'properties' => ['q' => ['type' => 'string'], 'a' => ['type' => 'string']]]],
],
]]],
]);
if ($res->failed()) {
throw new RuntimeException('OpenAI HTTP '.$res->status().': '.mb_substr($res->body(), 0, 300));
}
$text = collect($res->json('output', []))->where('type', 'message')->flatMap(fn ($m) => $m['content'] ?? [])
->where('type', 'output_text')->pluck('text')->implode('');
$out = json_decode($text, true);
if (! is_array($out) || empty($out['intro'])) {
throw new RuntimeException('The page draft came back empty.');
}
$page->update([
'status' => 'draft',
'title' => $out['title'],
'slug' => $page->slug ?: Str::slug($page->service->name.' '.$page->town->name),
'intro' => $out['intro'],
'job' => implode("\n\n", $out['job']),
'faqs' => $out['faqs'],
'error' => null,
]);
}
}
// —— app/Services/PageRenderer.php ——
<?php
namespace App\Services;
use App\Models\Page;
/**
* Builds the page. For WordPress it writes native blocks (paragraph, heading, image, quote, list), so the owner's theme
* styles them like its own pages and the owner can edit them in the normal editor. html() gives the same page as plain
* HTML for previews and for sites that aren't WordPress. Structured data comes from the master record.
*/
class PageRenderer
{
private const FACT_LABELS = [
'licences' => 'Licensed and insured', 'years' => 'Years in business', 'guarantee' => 'Our guarantee',
'response_time' => 'Response time', 'payment' => 'Payment and financing',
];
/** Native WordPress block markup. */
public function blocks(Page $page, string|array|null $photoUrl = null): string
{
$e = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES);
$out = [];
foreach ($this->parts($page, $photoUrl) as [$type, $value, $extra]) {
$out[] = match ($type) {
'p' => "<!-- wp:paragraph -->\n<p>{$value}</p>\n<!-- /wp:paragraph -->",
'h2' => "<!-- wp:heading -->\n<h2 class=\"wp-block-heading\">{$e($value)}</h2>\n<!-- /wp:heading -->",
'h3' => "<!-- wp:heading {\"level\":3} -->\n<h3 class=\"wp-block-heading\">{$e($value)}</h3>\n<!-- /wp:heading -->",
'img' => "<!-- wp:image {\"sizeSlug\":\"large\"} -->\n<figure class=\"wp-block-image size-large\"><img src=\"{$e($value)}\" alt=\"{$e($extra)}\"/></figure>\n<!-- /wp:image -->",
'quote' => "<!-- wp:quote -->\n<blockquote class=\"wp-block-quote\"><!-- wp:paragraph -->\n<p>{$e($value)}</p>\n<!-- /wp:paragraph -->".($extra ? "<cite>{$e($extra)}</cite>" : '')."</blockquote>\n<!-- /wp:quote -->",
'ul' => "<!-- wp:list -->\n<ul class=\"wp-block-list\">".implode('', array_map(fn ($li) => "<!-- wp:list-item -->\n<li>{$li}</li>\n<!-- /wp:list-item -->", $value))."</ul>\n<!-- /wp:list -->",
'schema' => "<!-- wp:html -->\n<script type=\"application/ld+json\">{$value}</script>\n<!-- /wp:html -->",
};
}
// A group block with a marker class; the small style keeps a readable width on full-width templates.
$class = 'ap-page '.$page->marker();
return "<!-- wp:html -->\n<style>.ap-page{max-width:48rem;margin-left:auto;margin-right:auto;padding-left:1.25rem;padding-right:1.25rem}</style>\n<!-- /wp:html -->\n"
."<!-- wp:group {\"className\":\"{$class}\"} -->\n<div class=\"wp-block-group {$class}\">\n".implode("\n\n", $out)."\n</div>\n<!-- /wp:group -->";
}
/** The same page as plain HTML. */
public function html(Page $page, string|array|null $photoUrl = null): string
{
$e = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES);
$out = [];
foreach ($this->parts($page, $photoUrl) as [$type, $value, $extra]) {
$out[] = match ($type) {
'p' => "<p>{$value}</p>",
'h2', 'h3' => "<{$type}>{$e($value)}</{$type}>",
'img' => '<figure><img src="'.$e($value).'" alt="'.$e($extra).'" loading="lazy"></figure>',
'quote' => '<blockquote><p>'.$e($value).'</p>'.($extra ? '<cite>'.$e($extra).'</cite>' : '').'</blockquote>',
'ul' => '<ul>'.implode('', array_map(fn ($li) => "<li>{$li}</li>", $value)).'</ul>',
'schema' => '<script type="application/ld+json">'.$value.'</script>',
};
}
return '<div class="ap-page '.$page->marker().'" style="max-width:48rem;margin:0 auto;padding:0 1.25rem">'."\n".implode("\n", $out)."\n</div>";
}
/** The page as an ordered list of [type, value, extra]. Paragraph and list values are already escaped HTML. */
private function parts(Page $page, string|array|null $photoUrl): array
{
$page->loadMissing('business.facts', 'service', 'town');
$b = $page->business;
$town = $page->town->name;
$service = $page->service->name;
$e = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES);
$p = $page->proof ?? [];
$parts = [];
$parts[] = ['p', $e($page->intro), null];
// Job photos: the first under the intro, the rest with "Our work in {town}".
$photoUrls = array_values(array_filter((array) $photoUrl));
if ($photoUrls) {
$parts[] = ['img', $photoUrls[0], $service.' job in '.$town];
}
$parts[] = ['h2', $service.' in '.$town.': what the job involves', null];
foreach (preg_split('/\n{2,}/', trim((string) $page->job)) as $para) {
$parts[] = ['p', $e($para), null];
}
$parts[] = ['h2', 'Our work in '.$town, null];
if (! empty($p['jobs'])) {
$parts[] = ['p', $e($p['jobs']), null];
}
if (! empty($p['landmark'])) {
$parts[] = ['p', 'We work around '.$e($p['landmark']).' and across '.$e($town).'.', null];
}
if (! empty($p['quote'])) {
$parts[] = ['quote', $p['quote'], $p['quote_name'] ?? null];
}
foreach (array_slice($photoUrls, 1) as $i => $url) {
$parts[] = ['img', $url, $service.' job in '.$town.' '.($i + 2)];
}
$facts = collect(self::FACT_LABELS)->map(fn ($label, $k) => $b->fact($k) ? '<strong>'.$e($label).':</strong> '.$e($b->fact($k)) : null)->filter()->values()->all();
if ($facts) {
$parts[] = ['h2', 'Good to know', null];
$parts[] = ['ul', $facts, null];
}
if ($page->faqs) {
$parts[] = ['h2', 'Questions about '.$this->midSentence($service).' in '.$town, null];
foreach ($page->faqs as $f) {
$parts[] = ['h3', $f['q'], null];
$parts[] = ['p', $e($f['a']), null];
}
}
$cta = array_filter([$b->fact('phone') ? 'Call '.$e($b->fact('phone')) : null,
$b->fact('booking_link') ? '<a href="'.$e($b->fact('booking_link')).'">book online</a>' : null]);
if ($cta) {
$parts[] = ['p', '<strong>Need '.$e($this->midSentence($service)).' in '.$e($town).'?</strong> '.implode(' or ', $cta).'.', null];
}
$parts[] = ['schema', json_encode($this->schema($page), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG), null];
return $parts;
}
public function schema(Page $page): array
{
$b = $page->business;
$area = ['@type' => 'City', 'name' => $page->town->name.($b->state() ? ', '.$b->state() : '')];
$business = array_filter([
'@type' => 'LocalBusiness', 'name' => $b->name, 'url' => $b->domain ? 'https://'.$b->domain.'/' : null,
'telephone' => $b->fact('phone'), 'address' => $b->address, 'areaServed' => $area,
]);
$faq = $page->faqs ? ['@type' => 'FAQPage', 'mainEntity' => array_map(fn ($f) => [
'@type' => 'Question', 'name' => $f['q'], 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $f['a']],
], $page->faqs)] : null;
return ['@context' => 'https://schema.org', '@graph' => array_values(array_filter([
['@type' => 'Service', 'serviceType' => $page->service->name, 'areaServed' => $area, 'provider' => $business],
$faq,
]))];
}
/** "Drain cleaning" reads as "drain cleaning" mid-sentence; "WordPress maintenance" and "HVAC repair" keep their capitals. */
private function midSentence(string $name): string
{
$first = strtok($name, ' ');
return preg_match('/^\p{Lu}[\p{Ll}\-]*$/u', (string) $first) ? mb_strtolower(mb_substr($name, 0, 1)).mb_substr($name, 1) : $name;
}
}
// —— app/Services/PagePublisher.php ——
<?php
namespace App\Services;
use App\Models\Page;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
/**
* Puts a finished page on the owner's WordPress in one of three ways:
* - update: new words on the existing page; same page, same address, its own template and look.
* - replace: a new page takes over an existing page's address; the old page is taken offline (kept as a draft).
* - new: add a new page.
* New and replacing pages copy the look (page template and parent) of the page the owner picked as the model.
* Every change is checked on the live page; if the new words don't show, the change is undone.
*/
class PagePublisher
{
public function __construct(private WordPress $wp, private PageRenderer $renderer, private LlmsSync $llms) {}
/** A draft on the owner's site, in their theme, to look at before anything goes live. */
public function preview(Page $page): string
{
$page->loadMissing('business', 'service', 'town');
$this->guard($page);
$b = $page->business;
$look = $this->look($page);
$fields = ['title' => $page->title, 'content' => $this->renderer->blocks($page, $this->photos($page)), 'status' => 'draft']
+ array_filter(['template' => $look['template']]);
[$id, $link] = $this->wp->savePage($b, $page->preview_wp_id, $fields);
$url = $link.(str_contains($link, '?') ? '&' : '?').'preview=true';
$page->update(['preview_wp_id' => $id, 'preview_url' => $url, 'status' => 'preview', 'error' => null]);
return $url;
}
public function publish(Page $page): void
{
$page->loadMissing('business', 'service', 'town');
$this->guard($page);
$content = $this->renderer->blocks($page, $this->photos($page));
match ($page->mode) {
'update' => $this->update($page, $content),
'replace' => $this->replace($page, $content),
default => $this->addNew($page, $content),
};
if ($page->preview_wp_id && $page->preview_wp_id !== $page->wp_post_id) {
$this->wp->trashPage($page->business, $page->preview_wp_id);
}
$page->update(['status' => 'published', 'published_at' => now(), 'error' => null, 'preview_wp_id' => null, 'preview_url' => null]);
$this->syncLlms($page);
}
/** A missing plugin must not undo a page that already published. The pages screen shows the reason. */
private function syncLlms(Page $page): void
{
$business = $page->business;
if (! $business->wordpressConnected()) {
return;
}
$business->unsetRelation('pages');
try {
$this->llms->push($business);
} catch (\Throwable) {
}
}
/** New words on the existing page. Its template, parent and address stay; our own copy of the old page is kept. */
private function update(Page $page, string $content): void
{
$b = $page->business;
$id = $page->wp_post_id ?: $this->wp->pageIdForUrl($b, $page->target_url) ?? throw new RuntimeException("Couldn't find {$page->target_url} on your site.");
$old = $this->wp->getPage($b, $id);
if (! $page->replaced_backup) {
$page->update(['replaced_backup' => $old + ['saved_at' => now()->toIso8601String()]]);
}
[, $link] = $this->wp->savePage($b, $id, ['title' => $page->title, 'content' => $content]);
if (! $this->wp->shows($link, $page->marker())) {
$this->wp->savePage($b, $id, ['title' => $old['title'], 'content' => $old['content']]);
throw new RuntimeException("Your page at {$link} didn't show the new words, so we put it back as it was. It may be built with a page builder: choose \"Replace an existing page\" instead.");
}
$page->update(['wp_post_id' => $id, 'wp_url' => $link]);
}
/** A new page takes over the old page's address; the old page goes offline as a draft, under a spare address. */
private function replace(Page $page, string $content): void
{
$b = $page->business;
if ($page->wp_post_id) { // already replaced once: just new words on our page
[, $link] = $this->wp->savePage($b, $page->wp_post_id, ['title' => $page->title, 'content' => $content]);
$page->update(['wp_url' => $link]);
return;
}
$oldId = $this->wp->pageIdForUrl($b, $page->target_url) ?? throw new RuntimeException("Couldn't find {$page->target_url} on your site.");
$old = $this->wp->getPage($b, $oldId);
$page->update(['replaced_backup' => $old + ['saved_at' => now()->toIso8601String()]]);
$look = $this->look($page);
$this->wp->savePage($b, $oldId, ['status' => 'draft', 'slug' => $old['slug'].'-old']);
try {
[$newId, $link] = $this->wp->savePage($b, $page->preview_wp_id, ['title' => $page->title, 'content' => $content, 'status' => 'publish',
'slug' => $old['slug'], 'parent' => $old['parent']] + array_filter(['template' => $look['template']]));
} catch (\Throwable $e) {
$this->wp->savePage($b, $oldId, ['status' => $old['status'], 'slug' => $old['slug']]);
throw $e;
}
if (! $this->wp->shows($link, $page->marker())) {
$this->wp->trashPage($b, $newId);
$this->wp->savePage($b, $oldId, ['status' => $old['status'], 'slug' => $old['slug']]);
throw new RuntimeException("The new page didn't show at {$link}, so we put your old page back as it was.");
}
$page->update(['wp_post_id' => $newId, 'wp_url' => $link, 'retired_wp_id' => $oldId, 'preview_wp_id' => null]);
}
private function addNew(Page $page, string $content): void
{
$b = $page->business;
$look = $this->look($page);
$id = $page->wp_post_id ?: $page->preview_wp_id; // the preview draft becomes the live page
$fields = ['title' => $page->title, 'content' => $content, 'status' => 'publish'];
if (! $page->wp_post_id) {
$fields += ['slug' => $page->slug] + array_filter(['template' => $look['template'], 'parent' => $look['parent']]);
}
[$id, $link] = $this->wp->savePage($b, $id, $fields);
if (! $this->wp->shows($link, $page->marker())) {
throw new RuntimeException("The page was saved but {$link} doesn't show it yet. Check it in WordPress.");
}
$page->update(['wp_post_id' => $id, 'wp_url' => $link, 'preview_wp_id' => null]);
}
/** Page template and parent to copy: the owner's model page (for update, the page keeps its own look). */
private function look(Page $page): array
{
$b = $page->business;
return ['template' => $b->wp_model_template ?: null, 'parent' => $b->wp_model_parent ?: null];
}
private function guard(Page $page): void
{
if (! $page->canPublish()) {
throw new RuntimeException('Add the local proof, and pick the existing page if you are updating or replacing one.');
}
if ($page->mode === 'update' && $page->target_builder) {
throw new RuntimeException("That page is built with {$page->target_builder}, so its words can't be updated. Choose \"Replace an existing page\".");
}
}
/** @return list<string> the job photos' addresses on the owner's site, uploading any not sent yet (the file keeps its location data). */
private function photos(Page $page): array
{
$proof = $page->proof ?? [];
$paths = $proof['photos'] ?? (! empty($proof['photo']) ? [$proof['photo']] : []);
$urls = $proof['photo_urls'] ?? (! empty($proof['photo_url']) && ! empty($proof['photo']) ? [$proof['photo'] => $proof['photo_url']] : []);
foreach ($paths as $i => $path) {
if (empty($urls[$path]) && Storage::disk('public')->exists($path)) {
$urls[$path] = $this->wp->uploadImage($page->business, Storage::disk('public')->path($path), basename($path),
$page->service->name.' job in '.$page->town->name.($i ? ' '.($i + 1) : ''));
}
}
$page->update(['proof' => array_merge($proof, ['photo_urls' => $urls])]);
return array_values(array_filter(array_map(fn ($p) => $urls[$p] ?? null, $paths)));
}
}
// —— app/Jobs/PublishPage.php ——
<?php
namespace App\Jobs;
use App\Models\Page;
use App\Services\PagePublisher;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class PublishPage implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 150;
public function __construct(public int $pageId) {}
public function handle(PagePublisher $publisher): void
{
if ($page = Page::find($this->pageId)) {
$publisher->publish($page);
}
}
public function failed(Throwable $e): void
{
Page::whereKey($this->pageId)->update(['status' => 'failed', 'error' => mb_substr($e->getMessage(), 0, 1000)]);
}
}
// —— app/Services/WordPress.php — builder detection and the live check ——
public function builderFor(string $url, ?int $id = null): ?string
{
try {
$html = Http::timeout(20)->get($url)->body();
} catch (\Throwable) {
return null;
}
$signs = [
'Elementor' => array_filter([$id ? 'elementor-'.$id.'"' : null, $id ? 'elementor-'.$id.' ' : null, 'data-elementor-type="wp-page"']),
'Divi' => ['et_pb_section', 'et-l--post'],
'Beaver Builder' => ['fl-builder-content'],
'WPBakery' => ['vc_row wpb_row'],
'Bricks' => ['brxe-'],
'Oxygen' => ['ct-section'],
'Thrive Architect' => ['tve_editor'],
];
foreach ($signs as $builder => $needles) {
foreach ($needles as $n) {
if (str_contains($html, $n)) {
return $builder;
}
}
}
return null;
}
/** The live page shows these words: looks for the page's marker class, skipping page caches, a few times. */
public function shows(string $url, string $marker, int $tries = 3): bool
{
for ($i = 0; $i < $tries; $i++) {
try {
$bust = $url.(str_contains($url, '?') ? '&' : '?').'ap_check='.bin2hex(random_bytes(4));
$res = Http::timeout(20)->withHeaders(['Cache-Control' => 'no-cache', 'Pragma' => 'no-cache'])->get($bust);
if ($res->status() === 200 && str_contains($res->body(), $marker)) {
return true;
}
} catch (\Throwable) {
}
if ($i < $tries - 1) {
sleep(app()->runningUnitTests() ? 0 : 3);
}
}
return false;
}