← Back to blog

Migrating from Laravel to Cloudflare Workers: Lessons Learned

· 8 min read · Cloudflare · Laravel

Three years ago, I was running a Laravel API behind an Nginx server on a DigitalOcean droplet in Bangalore. For users in Mumbai or Delhi, response times were acceptable. For users in Jakarta, Manila, or Ho Chi Minh City — they were waiting. Not catastrophically, but noticeably. P95 latency for those Southeast Asian users was sitting around 800ms for simple read operations. That’s the kind of number that quietly costs you conversions.

This is the story of how I moved critical parts of that system to Cloudflare Workers, what I got wrong the first time, and what the tradeoffs actually look like after running it in production for over a year.

Why Edge Computing, and Why Now

I’ve been writing PHP since before Laravel existed. Eight years of agency work meant I’ve shipped Laravel applications ranging from simple CRUD tools to fairly complex multi-tenant SaaS platforms. But Laravel runs on a server in a datacenter, and that datacenter is somewhere. Your users are everywhere else.

Cloudflare Workers runs your code in one of 300+ locations worldwide, close to whoever’s making the request. No server to provision. No region selection to agonize over. No auto-scaling rules to tune at 2am.

What finally pushed me to actually do the migration wasn’t a performance crisis. It was a client project where we were building a content API that needed to serve read-heavy traffic globally, and the TCO comparison on DigitalOcean vs Workers pricing made edge the obvious call. That forced me to learn the platform properly instead of just experimenting.

What We Moved to the Edge — and What We Didn’t

The first mistake people make with this kind of migration is treating it as all-or-nothing. You don’t replace Laravel with Cloudflare Workers. You identify the parts of your system where edge deployment creates real value, and you move those.

For us, the candidates were clear:

  • Public-facing read API endpoints (product listings, content feeds, configuration responses)
  • Authentication token validation and session checks
  • Webhook ingestion and fan-out
  • Image transformation and serving via R2
  • Rate limiting and abuse detection logic

What stayed in PHP:

  • Anything touching Eloquent and the relational database heavily (complex reporting, transactional writes)
  • Background job processing — Horizon is still running on the droplet
  • Admin panel (Filament-based, no reason to move it)
  • Third-party integrations that need persistent connections or long timeouts

Workers has a 30-second CPU time limit (on the paid plan) and no filesystem access. If your code assumes it can write to /tmp, read a config file from disk, or shell out to another process — none of that is available. This catches people off guard more than anything else in the migration.

The Migration Strategy: Strangler Fig, Not Big Bang

We ran the Laravel API and the Workers layer in parallel for about six weeks. Cloudflare’s routing let us point specific URL patterns to Workers while everything else continued hitting the origin. Route-by-route cutover rather than a big-bang switch — anything else is asking for a bad week.

The Wrangler CLI made this less painful than I expected. wrangler dev spins up a local development environment using Miniflare under the hood, which emulates KV namespaces, Durable Objects, and R2 locally. It’s not a perfect replica of the production runtime, but it’s close enough that I only hit production-specific surprises twice during the whole migration.

Here’s the base Worker handler structure we settled on — TypeScript throughout, which I’d strongly recommend over plain JavaScript:

export interface Env {
  CONTENT_KV: KVNamespace;
  SESSIONS_KV: KVNamespace;
  ORIGIN_API_URL: string;
  API_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const pathname = url.pathname;

    if (request.method === 'OPTIONS') {
      return handleCors(request);
    }

    try {
      if (pathname.startsWith('/api/v2/content')) {
        return await handleContentRequest(request, env, ctx);
      }

      if (pathname.startsWith('/api/v2/auth/validate')) {
        return await validateSession(request, env);
      }

      // Pass everything else to the Laravel origin
      return await fetch(request);
    } catch (err) {
      console.error('Worker error:', err);
      return new Response(JSON.stringify({ error: 'Internal error' }), {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      });
    }
  },
};

One thing that tripped me up early: environment variables in Workers are not set via .env files the way you’d do in Laravel. They live in wrangler.toml for non-secret values, and for secrets you use wrangler secret put SECRET_NAME, which stores them encrypted in Cloudflare’s infrastructure. Coming from php artisan config:cache and .env conventions, this took some adjustment. The mental model is different — you’re not configuring a server, you’re configuring a deployment.

KV Storage: What It Is and What It Isn’t

Cloudflare KV is eventually consistent global storage. If you’re used to Redis, the behavior is similar for reads, but the consistency guarantees are weaker for writes. A write in one region may take up to 60 seconds to propagate globally. For caching content responses, this is fine. For anything where consistency matters — don’t use KV.

Here’s the pattern we use for caching content from the Laravel origin into KV, with cache-aside logic and a grace period for stale content:

async function handleContentRequest(
  request: Request,
  env: Env,
  ctx: ExecutionContext
): Promise<Response> {
  const cacheKey = buildCacheKey(request);
  const corsHeaders = getCorsHeaders(request);

  // Try KV cache first
  const cached = await env.CONTENT_KV.getWithMetadata<{ cachedAt: number }>(
    cacheKey,
    { type: 'text' }
  );

  if (cached.value !== null && cached.metadata) {
    const age = Date.now() - cached.metadata.cachedAt;
    const isStale = age > 300_000; // 5 minutes

    if (!isStale) {
      return new Response(cached.value, {
        headers: {
          'Content-Type': 'application/json',
          'X-Cache': 'HIT',
          'X-Cache-Age': String(Math.floor(age / 1000)),
          ...corsHeaders,
        },
      });
    }

    // Revalidate in background, serve stale immediately
    ctx.waitUntil(revalidateFromOrigin(cacheKey, request, env));

    return new Response(cached.value, {
      headers: {
        'Content-Type': 'application/json',
        'X-Cache': 'STALE',
        ...corsHeaders,
      },
    });
  }

  // Cache miss — fetch from Laravel origin
  const originResponse = await fetchFromOrigin(request, env);
  const body = await originResponse.text();

  if (originResponse.ok) {
    ctx.waitUntil(
      env.CONTENT_KV.put(cacheKey, body, {
        expirationTtl: 3600,
        metadata: { cachedAt: Date.now() },
      })
    );
  }

  return new Response(body, {
    status: originResponse.status,
    headers: {
      'Content-Type': 'application/json',
      'X-Cache': 'MISS',
      ...corsHeaders,
    },
  });
}

The ctx.waitUntil() call is important — it tells the Workers runtime to keep the worker alive to complete that async task even after the response has been sent. Without it, background writes get killed when the response returns. This is one of those Workers-specific patterns that has no direct equivalent in a traditional request lifecycle.

CORS: More Painful Than It Should Be

CORS handling in Workers is entirely manual. There’s no middleware system like Laravel’s — you write the headers yourself. On one hand, this is annoying. On the other hand, you have complete control, and debugging CORS issues is actually easier when you can see exactly what headers are being set and why.

We ended up with a simple helper that reads the allowed origins from an environment variable and handles preflight requests correctly. The one gotcha: make sure your OPTIONS handler returns a 200 with the right headers before any authentication or routing logic runs, otherwise preflight requests fail before they even get a chance to be validated.

What About Durable Objects and D1?

We use Durable Objects for rate limiting. A single Durable Object instance per user ID acts as a consistent counter — requests from anywhere in the world route to the same instance, which means your rate limit state is actually accurate rather than per-region. The implementation is straightforward, and the strongly consistent nature of Durable Objects is exactly what you want for this use case.

D1, Cloudflare’s SQLite-based relational database, is interesting but we haven’t moved production workloads onto it yet. The API is clean and it supports standard SQL queries through a Workers binding. For new projects, I’d evaluate it seriously. For migrating existing Eloquent-heavy code, the gap between a full relational database and a SQLite edge database is real — especially if you’re relying on Laravel’s query builder for complex joins, or if you need foreign key constraints enforced reliably under load.

If you’re comparing the development experience: php artisan migrate and Eloquent model definitions are a genuinely more productive environment for complex data modeling than writing raw SQL for D1. That’s not a criticism of D1 — it’s a different tool for a different context.

The Numbers That Actually Mattered

After full deployment, P95 latency for content API reads dropped from around 800ms to 120ms for users in Southeast Asia. Users in Europe, who were already reasonably close to the Bangalore origin, saw less dramatic improvement — P95 went from roughly 350ms to 90ms. The global median for read endpoints is now under 60ms.

Error rates actually went up slightly during the first two weeks as we worked through edge cases in the Worker code that Miniflare hadn’t caught — mostly around request body handling and some assumptions our code made about header casing (the Workers runtime normalizes header names differently than PHP’s $_SERVER superglobals). Both issues were found and fixed quickly, but it’s a reminder that local dev environment parity is never perfect.

Infrastructure cost for the read traffic we moved dropped meaningfully. Workers’ pricing model — per-request plus CPU time — works out significantly cheaper than running servers sized for peak traffic when your actual traffic pattern has large variance.

What I’d Do Differently

Start with TypeScript. I wrote the first iteration in plain JavaScript to move faster, then spent two days adding types when the codebase got complicated enough that I was making mistakes. The Workers runtime types from @cloudflare/workers-types are comprehensive and catch real bugs. Just use TypeScript from day one.

Write integration tests that run against the actual Workers runtime before you go to production. Miniflare is good, but it’s not identical. Cloudflare’s own testing tools have improved — look at the unstable_dev API in Wrangler for programmatic test execution against a real local Workers instance.

Map your KV key schema before you write a single line of code. KV has no querying capability — you get a key or you scan a prefix. If you design yourself into a corner with your key naming, refactoring it later means a data migration. Think about it the same way you’d think about index design in a relational database, except there are no secondary indexes — just the key.

Don’t underestimate the observability gap. Laravel’s logging, telescope, and the ecosystem around it is mature. Workers logging to the Cloudflare dashboard is functional but basic. We ended up routing structured logs to an external service early on. Plan for this from the start rather than retrofitting it.

When Edge Migration Actually Makes Sense

The serverless migration from PHP to an edge runtime like Cloudflare Workers makes sense when your traffic is genuinely global and latency-sensitive, when you’re serving read-heavy workloads that can tolerate eventual consistency, and when your compute needs are request-scoped rather than long-running.

It doesn’t make sense when your application is deeply coupled to a relational database for every request, when you need filesystem access or long-running processes, or when your team is small and the operational overhead of maintaining two runtimes outweighs the performance gains.

The Laravel to Cloudflare Workers migration isn’t a replacement story — it’s a decomposition story. Laravel remains the right tool for complex business logic, relational data, and the admin and operational parts of your system. Workers is the right tool for the globally distributed, latency-sensitive, high-volume edge of your API surface.

Anyone who tells you one technology replaces another entirely is selling something. The real question is never “which platform wins?” — it’s where the boundary should sit between them. Get that boundary wrong and you’ll spend six months untangling a mess. Get it right and both sides evolve on their own schedule without drama.

One thing I didn’t cover here: the observability story on Workers is still rough compared to what you get with Laravel Telescope and structured logging. That gap is closing but it’s not closed. Factor that into your decision — especially if you’re a small team without dedicated ops support.