DEV Community

hisarkariresult
hisarkariresult

Posted on AI-assisted

Lessons from Running a 2,300-Page Government Jobs Portal on Laravel

We run HiSarkariResult, a Laravel site that tracks Indian government job notifications, exam results, admit cards, answer keys, syllabus pages and admission notices. Today our XML sitemap lists about 2,300 URLs, split roughly like this:

Section URLs in sitemap
Results ~1,230
Syllabus ~940
Jobs ~100
Admission ~40

That's not huge by web standards, but a site like this has some specific quirks. Content goes stale fast, the same exam shows up as a job, then an admit card, then an answer key and then a result, and every page has to point users to an official PDF on a government website. Here's what we've learned. None of the snippets below are copied from our codebase. They're generic examples of how you can do the same thing in your own Laravel app.

1. Model the content around its lifecycle

It's tempting to create a separate table for each section: jobs, results, admit_cards, syllabi. In practice these records share about 80% of their fields: title, slug, organisation, summary, important dates, official links. What changes is the stage in a recruitment cycle.

One way to handle that is a single posts table with a type column, plus a nullable recruitment_id that groups every post about the same exam:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('recruitment_id')->nullable()->constrained()->nullOnDelete();
    $table->string('type', 20)->index();   // job, result, admit_card, answer_key, syllabus, admission
    $table->string('slug')->unique();
    $table->string('title');
    $table->string('organisation_name')->nullable();
    $table->string('state')->nullable();             // e.g. Bihar; null for central posts
    $table->text('summary')->nullable();
    $table->longText('body');
    $table->string('official_url')->nullable();      // department homepage
    $table->string('notification_url')->nullable();  // official PDF
    $table->date('valid_through')->nullable();       // last date to apply, if any
    $table->timestamp('published_at')->nullable()->index();
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

A PHP backed enum keeps the allowed types in one place:

enum PostType: string
{
    case Job = 'job';
    case Result = 'result';
    case AdmitCard = 'admit_card';
    case AnswerKey = 'answer_key';
    case Syllabus = 'syllabus';
    case Admission = 'admission';

    public function prefix(): string
    {
        return match ($this) {
            self::Job => 'jobs',
            self::Result => 'results',
            self::AdmitCard => 'admit-card',
            self::AnswerKey => 'answer-key',
            self::Syllabus => 'syllabus',
            self::Admission => 'admission',
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
protected $casts = [
    'type' => PostType::class,
    'valid_through' => 'date',
    'published_at' => 'datetime',
];
Enter fullscreen mode Exit fullscreen mode

With recruitment_id in place, a result page can link to "the job notification for this exam" and the admit-card page with one query. That kind of internal linking helps users far more than a generic "latest posts" widget.

Tip: store official links in their own columns instead of burying them in the body HTML. Then you can validate them (for example, check that the host ends in .gov.in or .nic.in before a button says "Official"). You can also run a scheduled job that finds dead PDF links.

2. Make routes enforce the content type

If the URL prefix is /results/{slug}, the route should only resolve result posts. Otherwise a job post can be reached under /results/... and you end up with duplicate or mis-filed URLs. A small helper keeps this consistent:

// routes/web.php
foreach (PostType::cases() as $type) {
    Route::get("/{$type->prefix()}/{slug}", [PostController::class, 'show'])
        ->defaults('type', $type->value)
        ->name("posts.{$type->value}");
}
Enter fullscreen mode Exit fullscreen mode
public function show(Request $request, string $type, string $slug)
{
    $post = Post::where('type', $type)->where('slug', $slug)->first();

    if (! $post) {
        return $this->redirectOrNotFound($type, $slug);
    }

    return view('posts.show', compact('post'));
}
Enter fullscreen mode Exit fullscreen mode

For slugs, Str::slug() is fine, but generate the slug once when the post is created and never regenerate it from an edited title. Titles like "SBI Clerk Result 2026 Released" change often, and every change would break inbound links.

static::creating(function (Post $post) {
    $base = Str::slug(Str::limit($post->title, 80, ''));
    $slug = $base;
    $i = 2;

    while (static::where('slug', $slug)->exists()) {
        $slug = "{$base}-{$i}";
        $i++;
    }

    $post->slug = $slug;
});
Enter fullscreen mode Exit fullscreen mode

3. Handle expired and moved posts deliberately

Government notifications expire, but people keep searching for them long after the last date. Here's what worked better for us than deleting old posts:

  • Expired job: keep the page, show a clear "Applications closed on …" banner, and link to the related admit card or result. The page is still useful.
  • Moved or renamed post: return a 301 to the new URL.
  • Truly removed: return a real 404 (or 410) with a helpful error page. Never redirect everything to the homepage, because search engines treat that as a soft 404.

A tiny redirects table covers the moved case:

private function redirectOrNotFound(string $type, string $slug)
{
    $redirect = Redirect::where('from_path', request()->path())->first();

    if ($redirect) {
        return redirect($redirect->to_path, 301);
    }

    abort(404);
}
Enter fullscreen mode Exit fullscreen mode

Also make sure your templates never link to posts that no longer exist. A scheduled command that crawls your own homepage and listing pages and reports any 404s is cheap insurance.

4. Generate sitemaps as static files, in chunks

The sitemap protocol allows up to 50,000 URLs per file, but we prefer one sitemap per section behind a sitemap index. That makes Search Console reports much easier to read ("why are 300 syllabus pages not indexed?").

Building a sitemap on every request by loading every post into memory works at 2,000 rows and falls over at 200,000. Write the files from an Artisan command instead, streaming rows with lazyById():

class GenerateSitemaps extends Command
{
    protected $signature = 'sitemap:generate';

    public function handle(): void
    {
        $files = [];

        foreach (PostType::cases() as $type) {
            $path = public_path("sitemap-{$type->prefix()}.xml");
            $xml = new XMLWriter();
            $xml->openUri($path);
            $xml->startDocument('1.0', 'UTF-8');
            $xml->startElement('urlset');
            $xml->writeAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');

            Post::where('type', $type->value)
                ->whereNotNull('published_at')
                ->select(['id', 'slug', 'type', 'updated_at'])
                ->lazyById(1000)
                ->each(function (Post $post) use ($xml) {
                    $xml->startElement('url');
                    $xml->writeElement('loc', route("posts.{$post->type->value}", ['slug' => $post->slug]));
                    $xml->writeElement('lastmod', $post->updated_at->toAtomString());
                    $xml->endElement();
                });

            $xml->endElement();
            $xml->endDocument();
            $xml->flush();

            $files[] = basename($path);
        }

        $this->writeIndex($files); // same idea: <sitemapindex> with one <sitemap> per file
    }
}
Enter fullscreen mode Exit fullscreen mode

Schedule it and reference the index from robots.txt:

// routes/console.php (Laravel 11+)
Schedule::command('sitemap:generate')->hourly();
Enter fullscreen mode Exit fullscreen mode

Two small rules: leave out <priority> and <changefreq> (Google ignores them), and make sure every <loc> uses your canonical host and scheme. That brings us to the next point.

5. Choose one canonical URL and redirect everything else to it

A site can easily answer on four origins: http://, https://, with www and without. If all four return 200, you split signals across duplicates, and a canonical tag pointing to www while every internal link uses the bare domain sends mixed messages.

Pick one origin, set it as APP_URL, and redirect everything else with a single 301. On Apache, .htaccess can do this before Laravel even boots:

RewriteEngine On

# Force HTTPS and non-www in one hop
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [L,R=301]
Enter fullscreen mode Exit fullscreen mode

(Swap in your own domain. If you want www as canonical, invert the host condition.) Behind a load balancer or CDN, %{HTTPS} may always be off, so check X-Forwarded-Proto instead and configure Laravel's TrustProxies.

Inside Laravel, make generated URLs match:

// AppServiceProvider::boot()
if ($this->app->environment('production')) {
    URL::forceScheme('https');
    URL::forceRootUrl(config('app.url'));
}
Enter fullscreen mode Exit fullscreen mode
<link rel="canonical" href="{{ url()->current() }}">
Enter fullscreen mode Exit fullscreen mode

Once the root URL is forced, route(), url() and your sitemap all produce the same host, so the canonical tag, internal links and sitemap finally agree.

6. Production config: debug off, always

This is the lesson we'd put in bold for anyone shipping Laravel: APP_DEBUG must be false in production. With debug on, any unhandled exception (a route pointing at a controller method that doesn't exist, a failing query) renders a full developer error page. That page can reveal file paths, queries, environment details and package versions to anyone who hits the wrong URL.

APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
Enter fullscreen mode Exit fullscreen mode

Then cache the config:

php artisan config:cache
php artisan route:cache
php artisan view:cache
Enter fullscreen mode Exit fullscreen mode

Remember that after config:cache, changing .env does nothing until you re-run the command. Plenty of people "turn debug off" and see no difference for exactly this reason.

Add branded error views so users get something helpful instead of a blank page:

resources/views/errors/404.blade.php
resources/views/errors/500.blade.php
Enter fullscreen mode Exit fullscreen mode

Finally, add a smoke test that requests every parameter-free GET route and fails on a 500. It catches "route points to a method that doesn't exist" mistakes before users do:

public function test_static_get_routes_do_not_error(): void
{
    foreach (Route::getRoutes() as $route) {
        if (! in_array('GET', $route->methods()) || str_contains($route->uri(), '{')) {
            continue;
        }

        $response = $this->get('/' . ltrim($route->uri(), '/'));

        $this->assertLessThan(500, $response->status(), "GET /{$route->uri()} returned a server error");
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Add JobPosting structured data only to job pages

Google supports JobPosting markup, and it can make listings eligible for the jobs experience in search. Two rules matter for a portal like ours:

  1. Use it only on actual job notification pages, not on results, admit cards or syllabus pages.
  2. Set validThrough and remove the markup once the posting has expired.
public function jobPostingSchema(): ?array
{
    if ($this->type !== PostType::Job) {
        return null;
    }

    if ($this->valid_through && $this->valid_through->isPast()) {
        return null;
    }

    return array_filter([
        '@context' => 'https://schema.org',
        '@type' => 'JobPosting',
        'title' => $this->title,
        'description' => $this->body,
        'datePosted' => $this->published_at?->toDateString(),
        'validThrough' => $this->valid_through?->endOfDay()->toAtomString(),
        'hiringOrganization' => [
            '@type' => 'Organization',
            'name' => $this->organisation_name,
            'sameAs' => $this->official_url,
        ],
        'jobLocation' => [
            '@type' => 'Place',
            'address' => [
                '@type' => 'PostalAddress',
                'addressRegion' => $this->state,
                'addressCountry' => 'IN',
            ],
        ],
        'directApply' => false,
    ]);
}
Enter fullscreen mode Exit fullscreen mode
@if ($schema = $post->jobPostingSchema())
    <script type="application/ld+json">
        {!! json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}
    </script>
@endif
Enter fullscreen mode Exit fullscreen mode

JSON_HEX_TAG matters here: it escapes < and >, so a stray </script> in the description can't break out of the tag. Validate with Google's Rich Results Test after deploying.

Wrapping up

Most of these points aren't clever. They're the boring details that matter more as a content site grows:

  • model content around its lifecycle
  • make routes enforce types
  • never regenerate slugs
  • treat expired posts as content, not garbage
  • stream your sitemaps
  • pick one canonical origin
  • keep debug off
  • add structured data only where it's honest

We're applying these lessons on hisarkariresult.com as we go. If you've built a similar high-churn content site in Laravel, we'd love to hear how you handle expiry and redirects in the comments.

Top comments (0)