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 code

AnswerPins 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

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;
    }