Architecture

Jamstack with PHP: Static Sites and Decoupled Architecture

Build Jamstack sites with PHP backends. Covers static site generation, headless CMS patterns, API-driven architecture, and deploying PHP-powered Jamstack to CDNs.

Jamstack architecture diagram with PHP API backend and static frontend

Jamstack (JavaScript, APIs, Markup) is an architecture pattern, not a technology stack. The core idea is simple: pre-render as much as possible into static HTML, serve it from a CDN, and use APIs for dynamic functionality. PHP fits naturally as the API layer in this architecture.

This site—phpeveryday.com—is itself a static site built with Hexo (a Node.js generator) and deployed to Cloudflare Pages. The content is authored in Markdown, built into static HTML at deploy time, and served globally from Cloudflare’s edge network. No PHP server runs in production. This approach works for content-heavy sites where pages rarely change.

But many applications need dynamic data: user authentication, form submissions, search, e-commerce. This is where PHP enters the Jamstack architecture as the API backend.

How PHP fits in Jamstack

1
2
3
4
5
6
7
8
9
10
┌──────────────┐     Build time      ┌──────────────┐
│ PHP API │ ──────────────────> │ Static HTML │
│ (headless) │ │ (CDN-hosted) │
└──────────────┘ └──────────────┘
│ │
│ Runtime (AJAX/fetch) │
│ <────────────────────────────────> │
│ │
Dynamic data User's browser
(auth, forms, search)

PHP provides the data layer. The static frontend fetches data at build time (for pre-rendered pages) and at runtime (for dynamic interactions).

PHP as a headless CMS API

WordPress REST API

WordPress is the most common PHP headless CMS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// WordPress already has a REST API at /wp-json/wp/v2/
// Custom endpoint for the Jamstack frontend:
add_action('rest_api_init', function () {
register_rest_route('jamstack/v1', '/articles', [
'methods' => 'GET',
'callback' => function (WP_REST_Request $request) {
$articles = get_posts([
'post_type' => 'post',
'posts_per_page' => $request->get_param('limit') ?? 20,
'post_status' => 'publish',
]);

return array_map(fn($post) => [
'id' => $post->ID,
'title' => $post->post_title,
'slug' => $post->post_name,
'content' => apply_filters('the_content', $post->post_content),
'date' => $post->post_date,
'excerpt' => get_the_excerpt($post),
'featured_image' => get_the_post_thumbnail_url($post, 'large'),
], $articles);
},
'permission_callback' => '__return_true',
]);
});

Custom Laravel API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// routes/api.php
Route::get('/articles', function () {
return ArticleResource::collection(
Article::published()
->with(['author', 'tags'])
->orderByDesc('published_at')
->paginate(20)
);
});

Route::get('/articles/{slug}', function (string $slug) {
return new ArticleResource(
Article::where('slug', $slug)
->published()
->with(['author', 'tags', 'related'])
->firstOrFail()
);
});

Static frontend consuming PHP API

Next.js with PHP backend

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Next.js: pages/articles/[slug].js
export async function getStaticPaths() {
const res = await fetch('https://api.yoursite.com/articles');
const articles = await res.json();

return {
paths: articles.data.map(article => ({
params: { slug: article.slug },
})),
fallback: 'blocking', // Generate new pages on-demand
};
}

export async function getStaticProps({ params }) {
const res = await fetch(`https://api.yoursite.com/articles/${params.slug}`);
const article = await res.json();

return {
props: { article: article.data },
revalidate: 3600, // Rebuild this page every hour
};
}

Astro with PHP backend

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
---
// src/pages/articles/[slug].astro
export async function getStaticPaths() {
const response = await fetch('https://api.yoursite.com/articles');
const { data: articles } = await response.json();

return articles.map(article => ({
params: { slug: article.slug },
props: { article },
}));
}

const { article } = Astro.props;
---

<html>
<head><title>{article.title}</title></head>
<body>
<h1>{article.title}</h1>
<Fragment set:html={article.content} />
</body>
</html>

Nuxt with PHP backend

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- pages/articles/[slug].vue -->
<script setup>
const route = useRoute();
const { data: article } = await useFetch(
`https://api.yoursite.com/articles/${route.params.slug}`
);
</script>

<template>
<article>
<h1>{{ article.title }}</h1>
<div v-html="article.content" />
</article>
</template>

Deploy webhooks: rebuilding on content change

When content changes in the PHP CMS, the static site needs to rebuild. Deploy webhooks automate this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// In WordPress: trigger rebuild when a post is published
add_action('publish_post', function ($post_id) {
// Trigger Cloudflare Pages deploy hook
wp_remote_post('https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/YOUR_HOOK_ID', [
'timeout' => 5,
]);
});

// In Laravel: trigger rebuild when an article is published
class ArticleObserver
{
public function created(Article $article): void
{
if ($article->status === 'published') {
Http::post(config('services.cloudflare.deploy_hook'));
}
}
}

This is the same pattern this site uses: a daily Cloudflare Pages deploy hook rebuilds the site, and future-dated posts become visible when their publish date passes.

Handling dynamic functionality

Static sites need APIs for anything dynamic. PHP handles these well:

1
2
3
4
5
6
7
8
9
10
// PHP API: full-text search endpoint
Route::get('/api/search', function (Request $request) {
$query = $request->input('q');

return Article::whereFullText(['title', 'content'], $query)
->published()
->select(['id', 'title', 'slug', 'excerpt'])
->limit(20)
->get();
});
1
2
// Frontend: search with debouncing
const results = await fetch(`/api/search?q=${encodeURIComponent(query)}`);

Forms

1
2
3
4
5
6
7
8
9
10
11
12
// PHP API: contact form endpoint
Route::post('/api/contact', function (Request $request) {
$validated = $request->validate([
'name' => 'required|string|max:100',
'email' => 'required|email',
'message' => 'required|string|max:5000',
]);

Mail::to('hello@yoursite.com')->send(new ContactMessage($validated));

return response()->json(['status' => 'sent']);
});

Authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// PHP API: token-based auth for Jamstack frontend
Route::post('/api/login', function (Request $request) {
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);

if (!Auth::attempt($credentials)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials'],
]);
}

$token = $request->user()->createToken('spa');

return response()->json([
'token' => $token->plainTextToken,
'user' => new UserResource($request->user()),
]);
});

Architecture patterns

Pattern 1: Static with PHP API (this site’s pattern)

  • Content authored in Markdown/CMS
  • Static site generator builds HTML at deploy time
  • PHP API handles dynamic features (if any)
  • Deployed to CDN (Cloudflare Pages, Vercel, Netlify)

Best for: blogs, documentation, marketing sites.

Pattern 2: PHP renders initial HTML, JavaScript hydrates

  • PHP renders the first page load (fast TTFB, good SEO)
  • JavaScript framework hydrates interactive components
  • Subsequent navigation is client-side
1
2
3
4
5
// Laravel with Inertia.js
return Inertia::render('Article/Show', [
'article' => ArticleResource::make($article),
'related' => ArticleResource::collection($related),
]);

Best for: applications that need both SEO and interactivity.

Pattern 3: Full API separation

  • PHP API is completely separate from the frontend
  • Frontend is a standalone SPA or SSR application
  • Communication is entirely through REST/GraphQL APIs

Best for: applications with multiple frontends (web, mobile, third-party).

Common mistakes

Over-decoupling simple sites: A WordPress blog does not need a React frontend. Server-rendered WordPress with good caching serves most content sites better than a Jamstack architecture with more moving parts.

Ignoring build times: A 10,000-page site with full rebuilds on every change can take 15+ minutes. Plan for incremental builds or on-demand revalidation.

Forgetting CORS: The static frontend and PHP API are on different domains. PHP must return proper Access-Control-Allow-Origin headers.

Not caching API responses: The static frontend fetches from the PHP API at build time. If the API is slow, builds are slow. Cache API responses aggressively.

FAQ

Is Jamstack replacing traditional PHP sites?

For content sites, partially. For applications with authentication, forms, and real-time features, traditional server-rendered PHP is often simpler. Jamstack adds architectural complexity that only pays off at scale or when CDN performance is critical.

Can I use PHP as both the API and the static site generator?

Yes. Tools like Jigsaw (Laravel-based) and Sculpin generate static sites from PHP. However, the JavaScript ecosystem (Next.js, Astro, Nuxt) has more mature static generation tooling.

What about SEO?

Static HTML is ideal for SEO—search engines get fully rendered content immediately. If using client-side rendering, ensure critical content is in the initial HTML response.

Next steps

If your current PHP site is content-heavy and could benefit from CDN-edge delivery, start by extracting the content into an API endpoint and building a static prototype with Astro or Next.js. Compare the performance and developer experience before committing to the architecture change.

For the API backend, the REST API guide and the GraphQL guide cover how to build the PHP API layer that powers a Jamstack frontend. The headless WordPress guide covers using WordPress specifically as a headless CMS.