<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[HiSarkariResult Dev Blog]]></title><description><![CDATA[HiSarkariResult Dev Blog]]></description><link>https://hisarkariresult.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>HiSarkariResult Dev Blog</title><link>https://hisarkariresult.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 11 Oct 2026 11:25:47 GMT</lastBuildDate><atom:link href="https://hisarkariresult.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Treat Your SEO Output Like Code: Testable JobPosting JSON-LD and Paginated Sitemaps in Laravel]]></title><description><![CDATA[Structured data and sitemaps are usually handled once and then forgotten. Someone pastes a JSON-LD snippet into a Blade layout, installs a sitemap package, and nobody touches it again until Search Con]]></description><link>https://hisarkariresult.hashnode.dev/treat-your-seo-output-like-code-testable-jobposting-json-ld-and-paginated-sitemaps-in-laravel</link><guid isPermaLink="true">https://hisarkariresult.hashnode.dev/treat-your-seo-output-like-code-testable-jobposting-json-ld-and-paginated-sitemaps-in-laravel</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[hisarkariresult]]></dc:creator><pubDate>Wed, 30 Sep 2026 10:44:28 GMT</pubDate><content:encoded><![CDATA[<p>Structured data and sitemaps are usually handled once and then forgotten. Someone pastes a JSON-LD snippet into a Blade layout, installs a sitemap package, and nobody touches it again until Search Console starts reporting errors.</p>
<p>At <a href="https://hisarkariresult.com/">HiSarkariResult</a> we publish government job notifications, results and syllabus pages. Job pages expire, pay scales need to be accurate, and sections grow at very different rates. We learned that SEO output needs the same discipline as any other output: a clear owner in the codebase, and tests.</p>
<p>This post covers three patterns you can drop into a Laravel app:</p>
<ol>
<li><p>A small <strong>builder class</strong> for <code>JobPosting</code> JSON-LD instead of inline Blade arrays.</p>
</li>
<li><p><strong>Feature tests</strong> that render a page, extract the JSON-LD and assert on it.</p>
</li>
<li><p><strong>Paginated sitemap routes</strong> with HTTP caching (<code>Last-Modified</code> / <code>304</code>), as an alternative to pre-generating files.</p>
</li>
</ol>
<p>All the code is generic example code. Adapt names to your own models.</p>
<h2>1. A dedicated builder for JobPosting</h2>
<p>Google's <a href="https://developers.google.com/search/docs/appearance/structured-data/job-posting">JobPosting documentation</a> lists <code>title</code>, <code>description</code>, <code>datePosted</code>, <code>hiringOrganization</code> and <code>jobLocation</code> as required properties. It recommends <code>validThrough</code>, <code>employmentType</code>, <code>baseSalary</code> and <code>identifier</code>. When those rules live inside a Blade template they're hard to test, so we give them their own class:</p>
<pre><code class="language-php">namespace App\Seo;

use App\Models\Post;

final class JobPostingSchema
{
    public function __construct(private Post $post) {}

    public static function for(Post $post): self
    {
        return new self($post);
    }

    public function isEligible(): bool
    {
        return $this-&gt;post-&gt;type === 'job'
            &amp;&amp; $this-&gt;post-&gt;published_at !== null
            &amp;&amp; ($this-&gt;post-&gt;valid_through === null || $this-&gt;post-&gt;valid_through-&gt;isFuture());
    }

    public function toArray(): ?array
    {
        if (! $this-&gt;isEligible()) {
            return null;
        }

        $p = $this-&gt;post;

        return array_filter([
            '@context' =&gt; 'https://schema.org',
            '@type' =&gt; 'JobPosting',
            'title' =&gt; $p-&gt;post_name ?? $p-&gt;title,
            'description' =&gt; $p-&gt;body_html,
            'identifier' =&gt; $p-&gt;advertisement_no ? [
                '@type' =&gt; 'PropertyValue',
                'name' =&gt; $p-&gt;organisation_name,
                'value' =&gt; $p-&gt;advertisement_no,
            ] : null,
            'datePosted' =&gt; $p-&gt;published_at-&gt;toDateString(),
            'validThrough' =&gt; $p-&gt;valid_through?-&gt;copy()-&gt;endOfDay()-&gt;toAtomString(),
            'employmentType' =&gt; 'FULL_TIME',
            'hiringOrganization' =&gt; array_filter([
                '@type' =&gt; 'Organization',
                'name' =&gt; $p-&gt;organisation_name,
                'sameAs' =&gt; $p-&gt;official_url,
            ]),
            'jobLocation' =&gt; [
                '@type' =&gt; 'Place',
                'address' =&gt; array_filter([
                    '@type' =&gt; 'PostalAddress',
                    'addressRegion' =&gt; $p-&gt;state,
                    'addressCountry' =&gt; 'IN',
                ]),
            ],
            'baseSalary' =&gt; $this-&gt;salary(),
            'directApply' =&gt; false,
        ], fn ($v) =&gt; $v !== null &amp;&amp; $v !== []);
    }

    private function salary(): ?array
    {
        if (! $this-&gt;post-&gt;pay_min) {
            return null;
        }

        return [
            '@type' =&gt; 'MonetaryAmount',
            'currency' =&gt; 'INR',
            'value' =&gt; array_filter([
                '@type' =&gt; 'QuantitativeValue',
                'minValue' =&gt; $this-&gt;post-&gt;pay_min,
                'maxValue' =&gt; $this-&gt;post-&gt;pay_max,
                'unitText' =&gt; 'MONTH',
            ]),
        ];
    }

    public function toScriptTag(): string
    {
        $data = $this-&gt;toArray();

        if ($data === null) {
            return '';
        }

        $json = json_encode(
            $data,
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_THROW_ON_ERROR
        );

        return '&lt;script type="application/ld+json"&gt;' . $json . '&lt;/script&gt;';
    }
}
</code></pre>
<p>A few details that matter:</p>
<ul>
<li><p><strong>Salary ranges from the pay matrix.</strong> For Indian central government posts, the pay level in the notification maps to a published range. For example, the SSC CGL 2026 notice lists Pay Level-7 as ₹44,900 to ₹1,42,400. Store <code>pay_min</code> and <code>pay_max</code> from the notice rather than typing numbers into templates.</p>
</li>
<li><p><code>JSON_HEX_TAG</code> escapes <code>&lt;</code> and <code>&gt;</code>, so a description containing <code>&lt;/script&gt;</code> can't break out of the tag.</p>
</li>
<li><p><code>JSON_THROW_ON_ERROR</code> turns invalid UTF-8 from a copy-pasted PDF into a loud exception instead of a silent <code>false</code>.</p>
</li>
</ul>
<p>In Blade the template stays tiny:</p>
<pre><code class="language-blade">{!! \App\Seo\JobPostingSchema::for($post)-&gt;toScriptTag() !!}
</code></pre>
<h2>2. Unit-test the rules, feature-test the output</h2>
<p>Unit tests guard the business rules: which posts are eligible and which fields must exist.</p>
<pre><code class="language-php">use App\Models\Post;
use App\Seo\JobPostingSchema;

it('includes every property Google marks as required', function () {
    $post = Post::factory()-&gt;job()-&gt;create([
        'valid_through' =&gt; now()-&gt;addDays(10),
    ]);

    $schema = JobPostingSchema::for($post)-&gt;toArray();

    expect($schema)-&gt;toHaveKeys([
        'title', 'description', 'datePosted', 'hiringOrganization', 'jobLocation',
    ]);
});

it('drops the markup once the last date has passed', function () {
    $post = Post::factory()-&gt;job()-&gt;create([
        'valid_through' =&gt; now()-&gt;subDay(),
    ]);

    expect(JobPostingSchema::for($post)-&gt;toArray())-&gt;toBeNull();
});

it('never adds JobPosting to non-job content', function () {
    $post = Post::factory()-&gt;create(['type' =&gt; 'result']);

    expect(JobPostingSchema::for($post)-&gt;toArray())-&gt;toBeNull();
});
</code></pre>
<p>Feature tests catch the things unit tests miss: the partial not being included, a layout change swallowing the tag, or a second, conflicting JSON-LD block appearing. A small helper that parses the rendered HTML keeps the tests readable:</p>
<pre><code class="language-php">function jsonLdBlocks(string $html): array
{
    preg_match_all(
        '#&lt;script type="application/ld\+json"&gt;(.*?)&lt;/script&gt;#s',
        $html,
        $matches
    );

    return array_map(
        fn (string $json) =&gt; json_decode($json, true, flags: JSON_THROW_ON_ERROR),
        $matches[1]
    );
}

it('renders exactly one JobPosting block on an open job page', function () {
    $post = Post::factory()-&gt;job()-&gt;create(['valid_through' =&gt; now()-&gt;addWeek()]);

    $html = $this-&gt;get(route('posts.job', ['slug' =&gt; $post-&gt;slug]))
        -&gt;assertOk()
        -&gt;getContent();

    $types = array_column(jsonLdBlocks($html), '@type');

    expect(array_count_values($types)['JobPosting'] ?? 0)-&gt;toBe(1);
});
</code></pre>
<p>These are Pest tests. The same assertions work in PHPUnit with <code>assertArrayHasKey</code> and <code>assertNull</code>. Automated tests don't replace Google's <a href="https://search.google.com/test/rich-results">Rich Results Test</a>, but they stop regressions from reaching production in the first place.</p>
<h2>3. Paginated sitemap routes with HTTP caching</h2>
<p>Pre-generating sitemap files with a scheduled command works well. The alternative below suits sites that deploy to several servers or don't want a writable <code>public/</code> directory: serve sitemaps from routes, paginate them, and let HTTP caching do the heavy lifting.</p>
<pre><code class="language-php">// routes/web.php
Route::get('/sitemap.xml', [SitemapController::class, 'index']);
Route::get('/sitemaps/{section}-{page}.xml', [SitemapController::class, 'section'])
    -&gt;whereIn('section', ['jobs', 'results', 'syllabus', 'admission'])
    -&gt;whereNumber('page');
</code></pre>
<pre><code class="language-php">namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class SitemapController extends Controller
{
    private const PER_PAGE = 5000; // well under the protocol's 50,000-URL limit

    private const TYPES = [
        'jobs' =&gt; 'job',
        'results' =&gt; 'result',
        'syllabus' =&gt; 'syllabus',
        'admission' =&gt; 'admission',
    ];

    public function index(Request $request)
    {
        $files = [];

        foreach (self::TYPES as $section =&gt; $type) {
            $query = Post::where('type', $type)-&gt;whereNotNull('published_at');
            $pages = (int) ceil($query-&gt;count() / self::PER_PAGE);

            for ($page = 1; $page &lt;= $pages; $page++) {
                $files[] = url("/sitemaps/{$section}-{$page}.xml");
            }
        }

        return response()
            -&gt;view('sitemaps.index', ['files' =&gt; $files])
            -&gt;header('Content-Type', 'application/xml');
    }

    public function section(Request $request, string $section, int $page)
    {
        $query = Post::where('type', self::TYPES[$section])-&gt;whereNotNull('published_at');

        $lastModified = (clone $query)-&gt;max('updated_at');
        abort_if($lastModified === null, 404);

        $response = response()-&gt;noContent(200);
        $response-&gt;setLastModified(new \DateTime($lastModified));

        if ($response-&gt;isNotModified($request)) {
            return $response; // Symfony turns this into a 304 with no body
        }

        $posts = $query-&gt;orderBy('id')
            -&gt;forPage($page, self::PER_PAGE)
            -&gt;get(['id', 'slug', 'type', 'updated_at']);

        abort_if($posts-&gt;isEmpty(), 404);

        return response()
            -&gt;view('sitemaps.urlset', ['posts' =&gt; $posts])
            -&gt;header('Content-Type', 'application/xml')
            -&gt;setLastModified(new \DateTime($lastModified));
    }
}
</code></pre>
<p>The URL set view (use <code>{!! !!}</code> for the XML declaration so Blade doesn't choke on <code>&lt;?</code>):</p>
<pre><code class="language-blade">{!! '&lt;?xml version="1.0" encoding="UTF-8"?&gt;' !!}
&lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&gt;
@foreach ($posts as $post)
    &lt;url&gt;
        &lt;loc&gt;{{ route('posts.' . $post-&gt;type, ['slug' =&gt; $post-&gt;slug]) }}&lt;/loc&gt;
        &lt;lastmod&gt;{{ $post-&gt;updated_at-&gt;toAtomString() }}&lt;/lastmod&gt;
    &lt;/url&gt;
@endforeach
&lt;/urlset&gt;
</code></pre>
<p>Why the <code>Last-Modified</code> dance? Crawlers that send <code>If-Modified-Since</code> get an empty 304 when nothing in that section has changed, so an unchanged sitemap costs one <code>MAX(updated_at)</code> query instead of thousands of rows. Put a CDN or reverse proxy in front and the savings compound.</p>
<p>Two gotchas:</p>
<ul>
<li><p><strong>Ordering must be stable.</strong> Paginate by <code>id</code>, not by <code>updated_at</code>. Otherwise every edit shuffles URLs between pages, and crawlers see churn that isn't real.</p>
</li>
<li><p><strong>Don't rely on sitemap "pings".</strong> Google <a href="https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping">deprecated the sitemap ping endpoint in 2023</a>. Reference the index in <code>robots.txt</code>, submit it once in Search Console, and keep <code>&lt;lastmod&gt;</code> honest. Only bump <code>updated_at</code> when the content actually changes, not on every view count increment.</p>
</li>
</ul>
<h2>Test the sitemap too</h2>
<p>A quick feature test keeps the XML valid and the counts right:</p>
<pre><code class="language-php">it('lists published results in the first results sitemap', function () {
    $posts = Post::factory()-&gt;count(3)-&gt;create([
        'type' =&gt; 'result',
        'published_at' =&gt; now(),
    ]);

    $xml = simplexml_load_string(
        $this-&gt;get('/sitemaps/results-1.xml')-&gt;assertOk()-&gt;getContent()
    );

    expect(count($xml-&gt;url))-&gt;toBe(3);
});
</code></pre>
<h2>Wrapping up</h2>
<p>None of this is exotic. The shift is in mindset: JSON-LD and sitemaps are <strong>output your application produces</strong>, so give them a home in <code>app/</code>, cover them with tests, and let HTTP semantics handle caching. Search engines are just another client, and they deserve the same correctness as your users.</p>
]]></content:encoded></item></channel></rss>