<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mahmut Gündüzalp</title>
    <description>The latest articles on DEV Community by Mahmut Gündüzalp (@mahmut_gndzalp_c736ac4b).</description>
    <link>https://dev.to/mahmut_gndzalp_c736ac4b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2031144%2F507c6d99-33de-4ad3-b203-16e7eba3fc01.png</url>
      <title>DEV Community: Mahmut Gündüzalp</title>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mahmut_gndzalp_c736ac4b"/>
    <language>en</language>
    <item>
      <title>Self-Hosted MCP: Building a Model Context Protocol Server in PHP</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:11:26 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/self-hosted-mcp-building-a-model-context-protocol-server-in-php-36a2</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/self-hosted-mcp-building-a-model-context-protocol-server-in-php-36a2</guid>
      <description>&lt;p&gt;Most of the Model Context Protocol tutorials you'll find are written in TypeScript or Python, because those are the languages with official SDKs. That leaves a fair number of us — people maintaining PHP applications that hold years of business data — wondering whether the protocol is even available to us.&lt;/p&gt;

&lt;p&gt;It is. MCP is a wire protocol, not a library. If your language can read a line from standard input and write JSON back, you can implement a server in it. This post walks through what the protocol actually asks of you, what a PHP implementation looks like in outline, and — the part I underestimated — what changes when you expose an internal system to a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol is an open standard for connecting AI assistants to external systems: your data, your tools, your APIs. The problem it solves is combinatorial. Before a standard existed, every assistant needed a bespoke integration with every data source. MCP defines one interface so that any compliant client can talk to any compliant server.&lt;/p&gt;

&lt;p&gt;Underneath, it's &lt;strong&gt;JSON-RPC 2.0&lt;/strong&gt;. Requests carry a &lt;code&gt;jsonrpc&lt;/code&gt; version, a &lt;code&gt;method&lt;/code&gt;, a &lt;code&gt;params&lt;/code&gt; object, and an &lt;code&gt;id&lt;/code&gt;; responses carry the matching &lt;code&gt;id&lt;/code&gt; and either a &lt;code&gt;result&lt;/code&gt; or an &lt;code&gt;error&lt;/code&gt;. Notifications are requests without an &lt;code&gt;id&lt;/code&gt; and expect no reply. If you've implemented a JSON-RPC service before, you already know 80% of the transport story.&lt;/p&gt;

&lt;p&gt;A server exposes three kinds of primitive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt; — actions the model can invoke. Each has a name, a description, and a JSON Schema describing its inputs. This is the primitive that does something: query a database, create a record, send a request. The model chooses when to call them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resources&lt;/strong&gt; — data the model can read, addressed by URI. Files, records, generated documents. These are for context, not action, and the client generally decides what to pull in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts&lt;/strong&gt; — reusable prompt templates the user can invoke deliberately, often surfaced in the client's UI as a slash command or menu item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction between tools and resources matters more than it first appears. A rough rule: &lt;strong&gt;tools are model-controlled, resources are application-controlled.&lt;/strong&gt; If the model should decide whether to fetch something, make it a tool. If the user or the host application decides, make it a resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two transports
&lt;/h2&gt;

&lt;p&gt;MCP defines two standard transports, and picking the right one is the first architectural decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;stdio.&lt;/strong&gt; The client launches your server as a subprocess and talks to it over standard input and output — newline-delimited JSON, one message per line. This is the simplest possible setup: no ports, no HTTP server, no authentication layer, because the only thing that can talk to your process is the parent that spawned it. It's the right choice for anything running on the same machine as the client.&lt;/p&gt;

&lt;p&gt;Two rules with stdio, both easy to break in PHP:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never write anything but protocol messages to stdout.&lt;/strong&gt; A stray &lt;code&gt;echo&lt;/code&gt;, a &lt;code&gt;var_dump&lt;/code&gt; left in from debugging, or a PHP warning printed to stdout corrupts the message stream and the client will fail to parse it. Send diagnostics to &lt;strong&gt;stderr&lt;/strong&gt; instead, which the client typically forwards to a log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off output buffering and flush after every write&lt;/strong&gt;, or your responses sit in a buffer while the client waits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Streamable HTTP.&lt;/strong&gt; The server runs as an ordinary HTTP endpoint. The client POSTs JSON-RPC messages to it; the server replies with either a single JSON response or a stream of server-sent events when it needs to push multiple messages for one request. This is the transport for a server that runs somewhere other than the user's machine — which, for most PHP shops, is the interesting case, because that's the deployment model we already know how to operate.&lt;/p&gt;

&lt;p&gt;(An older HTTP+SSE transport exists in earlier revisions of the spec. New work should target Streamable HTTP.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The handshake
&lt;/h2&gt;

&lt;p&gt;Whichever transport you pick, the conversation starts the same way. The client sends &lt;code&gt;initialize&lt;/code&gt; with the protocol version it speaks and the capabilities it supports. Your server replies with its own protocol version, its capabilities, and its name and version. The client then sends an &lt;code&gt;initialized&lt;/code&gt; notification, and normal operation begins.&lt;/p&gt;

&lt;p&gt;Capabilities are how the two sides negotiate. If your server doesn't implement resources, you don't advertise the resources capability, and a well-behaved client won't call &lt;code&gt;resources/list&lt;/code&gt;. Don't advertise what you haven't built.&lt;/p&gt;

&lt;p&gt;After the handshake, the methods that matter are predictable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return the tools you expose, with descriptions and input schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Execute one tool with the given arguments, return its result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resources/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return available resources with their URIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resources/read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return the contents of one resource&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompts/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return available prompt templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompts/get&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Return one filled-in prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A minimal but genuinely useful server is &lt;code&gt;initialize&lt;/code&gt; plus &lt;code&gt;tools/list&lt;/code&gt; plus &lt;code&gt;tools/call&lt;/code&gt;. Everything else is optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the PHP side looks like
&lt;/h2&gt;

&lt;p&gt;The structural shape, transport-independent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read a JSON-RPC message
  → dispatch on `method`
  → build a result (or an error)
  → write the response with the same `id`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For stdio, that's a loop over &lt;code&gt;fgets(STDIN)&lt;/code&gt;, &lt;code&gt;json_decode&lt;/code&gt;, a &lt;code&gt;match&lt;/code&gt; on the method name, and &lt;code&gt;fwrite(STDOUT, json_encode($response) . "\n")&lt;/code&gt;. For Streamable HTTP, it's a single endpoint that decodes the request body and returns the encoded response. The dispatch layer in the middle is identical; only the read and write ends change. Write it that way from the start and you can support both from one codebase.&lt;/p&gt;

&lt;p&gt;Three PHP-specific things worth knowing before you start:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool schemas.&lt;/strong&gt; Every tool needs a JSON Schema for its inputs. Hand-writing those as nested arrays gets tedious fast. Deriving them from something you already maintain — a validation ruleset, a DTO, a set of typed constructor parameters read via reflection — keeps the schema and the actual implementation from drifting apart. Schema drift is the single most common cause of "the model keeps calling the tool wrong."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error handling.&lt;/strong&gt; Distinguish two kinds of failure. A malformed request or an unknown method is a &lt;strong&gt;protocol error&lt;/strong&gt;: return a JSON-RPC &lt;code&gt;error&lt;/code&gt; object. A tool that ran but failed — record not found, validation rejected the input — is a &lt;strong&gt;tool error&lt;/strong&gt;: return a normal result with the error flag set and a human-readable message. The distinction matters because the second kind goes back to the model, which can read the message and adjust. A protocol error just tells it something broke. Convert PHP exceptions into the second kind wherever the failure is something the model could plausibly recover from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-running work.&lt;/strong&gt; PHP's request-per-process model is a good fit for stdio (the process lives as long as the session) and a slightly awkward one for HTTP if a tool takes minutes. Keep tool calls short. If a tool kicks off something slow, return a job identifier immediately and expose a second tool that reports status. Models handle that pattern well; they handle a request that times out badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting it to Claude
&lt;/h2&gt;

&lt;p&gt;Once the server runs, there are three broad ways to reach it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local, over stdio.&lt;/strong&gt; Desktop and CLI clients — Claude Desktop, Claude Code, and various IDE integrations — let you register a server by specifying a command to run and its arguments (&lt;code&gt;php&lt;/code&gt;, plus the path to your server script), and they manage the subprocess for you. This is the fastest way to get from "it responds to &lt;code&gt;tools/list&lt;/code&gt;" to "I'm using it."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remote, over HTTP.&lt;/strong&gt; A Streamable HTTP server deployed at a URL can be registered with clients that support remote connections. The Claude API also has an MCP connector: you declare the server's URL in the request, and the API makes the connection server-side, so the model can call your tools without you writing a client loop. Note that hosted MCP servers generally authenticate with OAuth bearer tokens rather than a service's own native API key — those are different auth systems, and assuming the latter works is a common early stumble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From your own code.&lt;/strong&gt; If you're building an agent rather than using an existing client, most AI SDKs can convert MCP tool definitions into their native tool format, so an MCP server becomes a source of tools for a loop you control.&lt;/p&gt;

&lt;p&gt;Whichever route, &lt;strong&gt;debug over stdio first&lt;/strong&gt;. The failure modes are simpler: no TLS, no auth, no proxy, no CORS. Get the protocol right, then move it to HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I underestimated: exposure
&lt;/h2&gt;

&lt;p&gt;Here's the thing that changes when you put an MCP server in front of an existing system. Every tool you expose is a capability granted to a model that is, at least in part, steered by text it reads from the outside world. If the model can be persuaded to call your tool, your tool runs.&lt;/p&gt;

&lt;p&gt;The practical consequences:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope tools narrowly.&lt;/strong&gt; A generic &lt;code&gt;run_query&lt;/code&gt; tool that accepts arbitrary SQL is the most convenient thing to build and the worst thing to ship. Prefer specific tools with typed parameters — &lt;code&gt;find_customer_by_email&lt;/code&gt;, &lt;code&gt;list_orders_in_range&lt;/code&gt; — and validate every argument server-side as if it came from an anonymous HTTP request. It did, effectively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read and write are different risk classes.&lt;/strong&gt; Read-only tools have a disclosure risk. Write tools have a &lt;em&gt;this actually happened&lt;/em&gt; risk. Separate them, and put anything destructive or irreversible behind an explicit confirmation in the host application rather than trusting the model to be careful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The description is part of the security surface.&lt;/strong&gt; Tool descriptions are instructions the model reads. A vague description invites the model to call a tool in situations you didn't intend. Being prescriptive about &lt;em&gt;when&lt;/em&gt; a tool should be used — not just what it does — measurably improves both correctness and safety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't leak what you didn't mean to expose.&lt;/strong&gt; Return the fields the task needs, not whole records. An internal note, a cost price, or a personal phone number that shouldn't be in a customer-facing answer shouldn't be in a tool result either. Filter at the server, not in the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log every call.&lt;/strong&gt; Tool name, arguments, caller, outcome. When someone asks "why did it do that," the log is the only answer you'll have.&lt;/p&gt;

&lt;p&gt;None of this is exotic — it's the same discipline you'd apply to a public API endpoint. The difference is that the caller is a language model rather than a developer reading your docs, so ambiguity gets resolved in ways you didn't anticipate rather than surfacing as a support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth it?
&lt;/h2&gt;

&lt;p&gt;For a PHP application sitting on years of accumulated business data, MCP is the cheapest bridge I've found between that data and an assistant that can actually reason about it. There's no SDK to wait for. It's JSON-RPC over a pipe or an HTTP endpoint — both things PHP has done well for twenty years.&lt;/p&gt;

&lt;p&gt;Start with three read-only tools that answer questions someone in your organization asks weekly. Ship it over stdio to one person. See what they actually ask for. That's a far better spec than anything you'd design up front.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've built an MCP server in a language without an official SDK, I'd be curious what tripped you up — the transport, the schemas, or the scoping.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Generic CMS to Purpose-Built: What 200 Site Migrations Taught Me</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:09:31 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/from-generic-cms-to-purpose-built-what-200-site-migrations-taught-me-c0h</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/from-generic-cms-to-purpose-built-what-200-site-migrations-taught-me-c0h</guid>
      <description>&lt;p&gt;Migrating a live website to a new content management system is one of those jobs that looks like a data transfer and turns out to be an exercise in risk management. The database part is usually the easy half. The hard half is that a working site has years of accumulated meaning attached to it — URLs search engines memorised, editors' habits, a content model that drifted far from whatever it was on day one.&lt;/p&gt;

&lt;p&gt;Over the past years I've moved a few hundred sites — mostly news portals and online stores — off general-purpose, plugin-assembled CMS setups onto purpose-built platforms. Some of those migrations were clean. A few taught me things the hard way. This is the accumulated checklist, aimed at anyone about to do their first serious one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sites migrate at all
&lt;/h2&gt;

&lt;p&gt;Nobody migrates a CMS for fun. The trigger is almost always one of four things, and it's worth naming yours honestly before you start, because it determines what "success" means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin sprawl.&lt;/strong&gt; A site starts generic and grows domain-specific through add-ons: one plugin for the ticker, one for the photo gallery, one for the ad slots, one for the newsletter, three for SEO. Each one is maintained by a different party on a different release cadence. The site works, but nobody can predict what a routine update will break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance under spikes.&lt;/strong&gt; A general CMS with fifteen plugins executes a lot of code to render a page that is, semantically, one article. For a news site where a single story can bring 40× normal traffic in ten minutes, that overhead stops being academic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editorial friction.&lt;/strong&gt; This is the one clients articulate least well and feel most. If publishing a story with a photo gallery, a source credit, a related-articles block, and an agency attribution takes eleven clicks across four screens, your newsroom will invent workarounds, and your data model will slowly fill with those workarounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost of ownership.&lt;/strong&gt; Ten commercial add-on licences plus a maintenance retainer, renewed annually, forever.&lt;/p&gt;

&lt;p&gt;Write your trigger down. If it's performance, you need before/after numbers. If it's editorial friction, you need to time the actual publishing workflow before and after. A migration that can't be measured will be judged on vibes, and vibes after a migration are always bad for the first two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone underestimates: URLs
&lt;/h2&gt;

&lt;p&gt;Here is the single highest-risk element of any migration of an established site: &lt;strong&gt;the URL is not a detail, it's the asset.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A news site that has been publishing for eight years has tens of thousands of indexed URLs. Some carry external links. Some rank on page one for terms the business depends on. Every one of them that returns a 404 after cutover is value thrown away, and search engines are considerably slower to give it back than they were to take it.&lt;/p&gt;

&lt;p&gt;So before touching anything else:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Export the complete URL inventory.&lt;/strong&gt; Not the sitemap — the sitemap is what the old CMS &lt;em&gt;thinks&lt;/em&gt; exists. Pull the real list from server access logs (every distinct path with a 200 in the last 12 months), from the search console's indexed-pages report, and from the database. Union all three. The gap between the sitemap and the logs is routinely 20–30%, and it's where the old, still-ranking content lives.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Classify it.&lt;/strong&gt; Articles, category pages, tag pages, author pages, static pages, media files, feeds, pagination, and the long tail of oddities — print views, AMP variants, tracking-parameter duplicates, dated archive pages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Decide the target URL scheme once, on purpose.&lt;/strong&gt; If the new system can preserve the existing pattern, preserve it. Redesigning URLs "while we're in there" doubles the risk of the migration for aesthetic gain. If you genuinely must change the scheme, you now owe every old URL a permanent redirect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the redirect map as data, not as rules.&lt;/strong&gt; Regex rules feel elegant and fail silently on the exceptions. A generated lookup table of old path → new path, produced from the actual inventory, is boring and verifiable. You can test all 40,000 entries automatically; you cannot test a regex against the URLs you forgot existed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify with a crawl, not a spot check.&lt;/strong&gt; After cutover, crawl the full old inventory against the live site and assert: status is 301 (not 302, not 200-with-different-content), the destination resolves 200 in one hop, and no chains or loops exist. Redirect chains are where migrations quietly leak ranking.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule I'd tattoo on a junior developer: &lt;strong&gt;a migration is not done when the content is moved; it's done when the old URLs still work.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Content models never map one-to-one
&lt;/h2&gt;

&lt;p&gt;The second discovery of every migration is that the old content model is not what the documentation says. It's what editors made it.&lt;/p&gt;

&lt;p&gt;You will find, reliably:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "category" that is actually used as a workflow flag ("Ready", "Legal check").&lt;/li&gt;
&lt;li&gt;Three tags that mean the same thing with different capitalisation, plus one with a trailing space.&lt;/li&gt;
&lt;li&gt;HTML pasted into a plain-text summary field, because the summary needed a line break in 2019.&lt;/li&gt;
&lt;li&gt;Images referenced by absolute URL to the old domain, inside article bodies.&lt;/li&gt;
&lt;li&gt;A custom field that is empty on 90% of records and load-bearing on the other 10%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two practical responses. First, &lt;strong&gt;profile before you map.&lt;/strong&gt; For every field you intend to migrate, run the actual distribution: how many distinct values, how many nulls, the longest value, how many contain HTML, how many contain absolute URLs. Ten minutes of profiling prevents a mapping decision built on an assumption.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;decide explicitly what not to migrate.&lt;/strong&gt; Migration is the one moment when dropping dead weight is cheap. Fifteen years of unused tags, an abandoned events module, a decade of spam comments — carrying them forward means maintaining them forever. Just make the decision consciously and record it, rather than discovering the loss in month three.&lt;/p&gt;

&lt;p&gt;The transformation itself I'd keep as a repeatable, re-runnable script, never a hand-edited dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read source record
  → normalise (trim, fix encoding, resolve relative media paths)
  → map fields to target model
  → validate (required fields present, slug unique, dates sane)
  → write, or write to a rejects file with the reason
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-runnable matters more than it sounds. You will run the migration many times — against a copy, into staging, into a rehearsal, and finally for real. If the process is a script that takes the source and produces the target from scratch, every run is identical and every fix is permanent. If it's a script plus "and then I manually fixed those 40 rows", you have no repeatable process, and the final run will differ from the rehearsal in ways you can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Media is where the disk space and the surprises are
&lt;/h2&gt;

&lt;p&gt;Content rows are small. Media is not, and it's messier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Files referenced in article bodies by absolute URL — these break the moment the domain or path changes and must be rewritten during transformation.&lt;/li&gt;
&lt;li&gt;Orphan files: uploaded, never referenced. Often 30–50% of the media directory.&lt;/li&gt;
&lt;li&gt;Referenced files that don't exist. Someone cleaned up a folder in 2021.&lt;/li&gt;
&lt;li&gt;Generated thumbnail variants in a naming scheme the new system doesn't share.&lt;/li&gt;
&lt;li&gt;Filenames with non-ASCII characters and spaces, which behave differently across filesystems and web servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the migration, produce two lists: &lt;strong&gt;referenced-but-missing&lt;/strong&gt; (fix or accept — these become broken images), and &lt;strong&gt;present-but-unreferenced&lt;/strong&gt; (usually skip). Never regenerate thumbnails on first request under production traffic; pre-generate them during the migration, or your launch-day load test will be an unintentional one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rehearse the cutover, then rehearse it again
&lt;/h2&gt;

&lt;p&gt;The cutover is a sequence of steps under time pressure, which is exactly the condition in which people improvise. So write it down as a runbook with times, owners, and — for each step — the answer to "how do we undo this?"&lt;/p&gt;

&lt;p&gt;A workable shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;T-7 days:&lt;/strong&gt; full migration into staging with production data. Editors do real work in it for a week. This is the only reliable way to surface editorial-workflow problems, and it's the step most often skipped for schedule reasons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T-2 days:&lt;/strong&gt; dry run of the entire cutover against staging, stopwatch running. You now know how long the real one takes. It's always longer than the estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T-1 day:&lt;/strong&gt; DNS TTL lowered. Content freeze agreed with the newsroom, in writing, with a named person responsible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T-0:&lt;/strong&gt; final delta sync (only content created since the main import), switch, smoke tests, redirect crawl.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T+1 hour:&lt;/strong&gt; the checks below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T+7 days:&lt;/strong&gt; daily monitoring of crawl errors and index coverage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pick your window by traffic, not by convention.&lt;/strong&gt; For a news site the quiet hour is usually early morning local time — but check the actual analytics, and check the editorial calendar. Cutting over the night before a major scheduled news event is a self-inflicted wound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Define the rollback before you need it.&lt;/strong&gt; What's the trigger (error rate above X, homepage down more than Y minutes)? Who decides? How long does reverting take? A rollback plan that takes four hours isn't a rollback plan; it's a wish. In practice this usually means: keep the old stack running and untouched, switch at the DNS or proxy layer, and don't decommission anything for at least a fortnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in the first hour, and the first month
&lt;/h2&gt;

&lt;p&gt;Immediately after cutover, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Homepage and a sample of article pages return 200 with the right content.&lt;/li&gt;
&lt;li&gt;The redirect crawl passes across the full old-URL inventory.&lt;/li&gt;
&lt;li&gt;Feeds and the sitemap are valid and reachable; submit the new sitemap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; is the production one. (Staging's &lt;code&gt;Disallow: /&lt;/code&gt; reaching production is a classic, and it is genuinely expensive.)&lt;/li&gt;
&lt;li&gt;Canonical tags and structured data render correctly on article pages.&lt;/li&gt;
&lt;li&gt;Search: does querying an old, well-known headline return it?&lt;/li&gt;
&lt;li&gt;Publishing works end to end — an editor publishes a real story with an image while you watch.&lt;/li&gt;
&lt;li&gt;Analytics and any ad slots are firing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then over the following weeks, watch &lt;strong&gt;crawl errors&lt;/strong&gt; (a slow rise in 404s means the URL inventory had gaps), &lt;strong&gt;index coverage&lt;/strong&gt; (a drop means something is deindexing content — check canonicals and robots directives first), &lt;strong&gt;server response time under real traffic&lt;/strong&gt;, and &lt;strong&gt;the numbers that justified the migration in the first place&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Expect a short dip in traffic. A one-to-two week wobble while search engines re-crawl is normal even on a clean migration. A sustained four-week decline is not a wobble; it's a bug, and it's nearly always URLs, canonicals, or robots directives — in that order of likelihood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things I'd tell myself before the first one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Preserve URLs. Everything else is negotiable.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile the real data before mapping it.&lt;/strong&gt; The schema is a claim; the data is the fact.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the migration a re-runnable script, never a manual fix-up.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse with editors, not just with developers.&lt;/strong&gt; They will find in one afternoon what a test suite won't find in a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the old system alive and reversible for two weeks.&lt;/strong&gt; It costs a little hosting and buys a lot of sleep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the thing you migrated for.&lt;/strong&gt; Otherwise the only feedback you'll get is the first complaint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. A CMS migration is mostly the discipline to do unglamorous inventory work before the interesting part, and the honesty to define in advance what "it went wrong" looks like.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've done a large migration, I'm curious what bit you that isn't on this list — the failure modes seem to be endlessly inventive.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cms</category>
      <category>seo</category>
      <category>architecture</category>
    </item>
    <item>
      <title>PHP 8.2 in 2026: Why It's Still the Best Choice for News &amp; E-commerce CMS</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:16:52 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/php-82-in-2026-why-its-still-the-best-choice-for-news-e-commerce-cms-2hb5</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/php-82-in-2026-why-its-still-the-best-choice-for-news-e-commerce-cms-2hb5</guid>
      <description>&lt;p&gt;Every year someone announces that PHP is finished, and every year a large slice of the web keeps running on it — including most of the news portals and online stores I work with. That's not nostalgia. After building content and commerce systems for 200+ production sites, PHP 8.2 keeps winning the specific fight that matters for this domain: &lt;em&gt;shipping a stable, fast, server-rendered site that a small team can maintain for years without a rewrite.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This isn't a "PHP is actually cool now" hype piece. It's the concrete reasons a boring, typed, request-per-page runtime is still the right default for news and e-commerce CMS work in 2026 — and the honest places where it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workload, not the benchmark
&lt;/h2&gt;

&lt;p&gt;News and commerce sites have a very particular shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-heavy, cache-friendly.&lt;/strong&gt; Most visitors are anonymous and see the same article or product page. You want to render HTML on the server, cache it hard, and get out of the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spiky.&lt;/strong&gt; A breaking story or a campaign can multiply traffic in minutes. The runtime has to degrade gracefully, not fall over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-lived.&lt;/strong&gt; These sites run for 5–10 years. The team that maintains them in year 6 is rarely the team that built them in year 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO-critical.&lt;/strong&gt; For news, Google News and fast Largest Contentful Paint aren't nice-to-haves; they're the business.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PHP's execution model fits this shape almost embarrassingly well. Each request starts clean, does its work, and dies. No long-lived process accumulating memory leaks, no shared mutable state to reason about across requests. For a page that reads from a database, renders a template, and returns HTML, "shared-nothing" is a feature, not a limitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 8.2 actually gave us
&lt;/h2&gt;

&lt;p&gt;The jump from PHP 5.x/7.x thinking to 8.2 changed how these codebases read. A few features carry most of the weight in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;readonly&lt;/code&gt; properties&lt;/strong&gt; made value objects trustworthy. In a CMS you pass a lot of small immutable things around — a resolved article, a price with currency, a category node. Being able to say "this cannot change after construction" at the language level removes a whole category of "who mutated this?" bugs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// minor units (kuruş/cents)&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'TRY', 'USD'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;withVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$ratePercent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$ratePercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No setter, no accidental mutation three layers down, and money math stays in integer minor units where it belongs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enums&lt;/strong&gt; (from 8.1, but they land fully in 8.2 codebases) replaced the pile of &lt;code&gt;const STATUS_DRAFT = 0&lt;/code&gt; integers that every legacy CMS drags around. An article status or an order state becomes a real type the IDE and the type checker understand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Pending&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Paid&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Shipped&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nc"&gt;Refunded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'refunded'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isFinal&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Refunded&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Shipped&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Constructor promotion + typed properties&lt;/strong&gt; cut the ceremony that made older PHP feel heavy. A service class is now mostly signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleRenderer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;TemplateEngine&lt;/span&gt; &lt;span class="nv"&gt;$view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;CacheInterface&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;never&lt;/code&gt; return types, &lt;code&gt;readonly&lt;/code&gt; classes, and stricter type coercion&lt;/strong&gt; together mean the type checker catches far more before code ever reaches staging. On a long-lived codebase, that's the difference between refactoring with confidence and refactoring with prayer.&lt;/p&gt;

&lt;p&gt;None of these are flashy. Collectively they turn PHP from "scripting language you tolerate" into "typed application language that happens to have the best deployment story on the web."&lt;/p&gt;

&lt;h2&gt;
  
  
  Server-rendered HTML is a competitive advantage again
&lt;/h2&gt;

&lt;p&gt;For a few years the default answer to "how do I build the frontend?" was a JavaScript SPA. For a news article or a product page, that was almost always the wrong trade. You paid a bundle-size and complexity tax to re-implement, in the browser, the one thing the server already does perfectly: turn data into HTML.&lt;/p&gt;

&lt;p&gt;PHP renders HTML on the server as its native act. Pair it with a template engine and a sprinkle of hypermedia-style JavaScript for the genuinely interactive bits (filters, infinite scroll, cart updates) and you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First paint that doesn't wait on a JS runtime — good for LCP, good for Google News.&lt;/li&gt;
&lt;li&gt;HTML that's fully present for crawlers and AI scrapers without a headless-render step.&lt;/li&gt;
&lt;li&gt;A frontend a backend developer can maintain, instead of a second full stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The industry rediscovering server-rendered HTML in 2024–2026 was, from a PHP seat, watching everyone walk back to where the language already stood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the spikes: cache, don't scale
&lt;/h2&gt;

&lt;p&gt;The read-heavy, spiky profile has a well-worn answer in PHP land, and 8.2's execution model makes it clean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Full-page cache&lt;/strong&gt; for anonymous traffic. Most visitors on a breaking story are logged out and identical; serve them a cached HTML page and never touch the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opcode + preloading&lt;/strong&gt; so the framework itself isn't re-parsed on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cache layer that switches on under load&lt;/strong&gt; rather than being always-on — normal traffic hits the database for freshness, a spike flips the site into aggressive caching automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because each request is isolated, there's no warm-up state to protect and no cross-request corruption to worry about when you throw a cache in front. The mental model stays simple, which is exactly what you want at 3 a.m. when a story goes viral.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance argument nobody puts on slides
&lt;/h2&gt;

&lt;p&gt;The feature that keeps me choosing PHP 8.2 for client work isn't in any release note: &lt;strong&gt;you can hand the codebase to a different developer in year 4 and they can be productive in a week.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The request lifecycle is obvious. Request in, response out.&lt;/li&gt;
&lt;li&gt;Deployment is &lt;code&gt;rsync&lt;/code&gt; and a cache flush, not an orchestration diagram.&lt;/li&gt;
&lt;li&gt;The hosting is everywhere and cheap, which matters enormously for regional news sites and small stores.&lt;/li&gt;
&lt;li&gt;The type system now documents intent well enough that a newcomer can read a service class and know what it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For software that has to &lt;em&gt;outlive its authors&lt;/em&gt;, that boringness is the whole point. A clever runtime that only its original author understands is a liability on a 10-year site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where PHP 8.2 is &lt;em&gt;not&lt;/em&gt; the answer
&lt;/h2&gt;

&lt;p&gt;Being honest keeps this credible. I don't reach for PHP when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The workload is long-lived and stateful&lt;/strong&gt; — a websocket server, a real-time collaboration backend, a streaming pipeline. Shared-nothing-per-request is the wrong shape there; that's a job for a long-running process in another runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The product is fundamentally a rich client app&lt;/strong&gt; — a design tool, an editor with heavy live interaction. That genuinely wants a real frontend stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need CPU-bound number crunching.&lt;/strong&gt; PHP will do it; it won't be the tool you're happy with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;News and e-commerce CMS work is none of those. It's data-in, HTML-out, cache-heavy, maintained-for-years. That's PHP's home field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 verdict
&lt;/h2&gt;

&lt;p&gt;Language choice is a maintenance decision disguised as a technical one. For content and commerce systems, the constraints that actually bite are: fast server-rendered pages, graceful behavior under spikes, cheap ubiquitous hosting, and a codebase a small team can still understand years later. PHP 8.2 — typed, &lt;code&gt;readonly&lt;/code&gt;, enum-shaped, opcode-cached — hits every one of those without asking you to be clever.&lt;/p&gt;

&lt;p&gt;That's why the sites I build for news and e-commerce still start from PHP 8.2, and why I expect them to still be running, and still be maintainable, long after the next "PHP is dead" post. If you want to see what a modern PHP news and e-commerce stack looks like in production, that's the whole business at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;alestaweb.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pick the runtime you can still afford to maintain in year six. For this domain, that's still PHP.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building Newsroom AI Modules in PHP: 50+ Specialized Workflows</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Fri, 26 Jun 2026 08:50:23 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/building-newsroom-ai-modules-in-php-50-specialized-workflows-2co1</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/building-newsroom-ai-modules-in-php-50-specialized-workflows-2co1</guid>
      <description>&lt;p&gt;When people picture "AI in a newsroom," they usually imagine one big chat box bolted onto the editor. That's the wrong mental model, and it's the reason most of those features get used twice and then ignored.&lt;/p&gt;

&lt;p&gt;A newsroom doesn't have &lt;em&gt;one&lt;/em&gt; AI need. It has dozens of small, specific, repetitive ones: write three headline options, pull a 280-character social blurb, suggest a category, tag entities, generate alt text for the lead image, flag a defamation risk, translate the dek into English, propose an SEO title under 60 characters. Each is a tiny job with its own input shape, its own output contract, and its own tolerance for being wrong.&lt;/p&gt;

&lt;p&gt;After running this across 200+ news sites, the architecture that actually stuck was not "an AI feature." It was a &lt;strong&gt;registry of small, specialized workflows&lt;/strong&gt; behind a uniform interface. This article is how that's built in PHP, and the handful of decisions that matter more than which model you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit of work: a task, not a prompt
&lt;/h2&gt;

&lt;p&gt;The first thing to get right is the boundary. The reusable unit is a &lt;strong&gt;task&lt;/strong&gt; — a named workflow with a fixed contract — not a free-floating prompt string. A task knows three things: what it needs, what it promises to return, and how expensive it's allowed to be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AiTask&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 'headline.suggest'&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 'cheap' | 'standard' | 'premium'&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// -&amp;gt; messages for the model&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// -&amp;gt; validated structured output&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;parse()&lt;/code&gt; method is the part teams skip and regret. A model that returns prose when you expected JSON is not an edge case — it's Tuesday. If every task is responsible for validating its own output, a bad response fails &lt;em&gt;inside the task&lt;/em&gt; where you can retry or fall back, instead of leaking malformed data into the editor.&lt;/p&gt;

&lt;p&gt;A concrete task looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HeadlineSuggestTask&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AiTask&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'headline.suggest'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
                &lt;span class="s1"&gt;'You are a Turkish news copy editor. Return exactly 3 headline '&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'options as a JSON array of strings. Max 70 characters each. '&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'No clickbait, no ALL CAPS.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;mb_substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stripFences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BadOutputException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'headline.suggest: not a list'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'strval'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have one of these, you have the shape for fifty. The interesting work isn't writing the fiftieth task — it's the machinery around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "50+" is an architecture choice, not a brag
&lt;/h2&gt;

&lt;p&gt;The number isn't the point; the &lt;em&gt;granularity&lt;/em&gt; is. You could collapse "suggest headline," "suggest SEO title," and "suggest social blurb" into one mega-prompt that returns all three. Don't. Three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different cost tiers.&lt;/strong&gt; A category suggestion can run on a fast, cheap model. A legal-risk flag should not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different failure handling.&lt;/strong&gt; If headline generation fails, you shrug and the editor types one. If entity tagging fails silently, your archive search quietly rots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different UX surfaces.&lt;/strong&gt; The blurb belongs to the social scheduler; the alt text belongs to the image picker. Coupling them in one call couples two unrelated screens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small tasks compose. Big prompts calcify.&lt;/p&gt;

&lt;p&gt;Here's the rough taxonomy that emerged — grouped, because grouping is how editors actually find them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Example tasks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Headlines &amp;amp; framing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;headline options, SEO title, social blurb, push-notification text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Structure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;summary/dek, key-points list, "read more" suggestions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Classification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;category suggest, tag/entity extraction, topic clustering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Media&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;image alt text, caption draft, thumbnail crop hint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Quality &amp;amp; risk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;tone check, defamation/risk flag, fact-claim highlighter, profanity filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEO &amp;amp; distribution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;meta description, schema keywords, related-article linking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;translate dek, simplify, localize idiom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's already 25+ before you count per-language and per-section variants. The registry is what keeps it from becoming chaos.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry and the router
&lt;/h2&gt;

&lt;p&gt;The registry is boring on purpose: a name-to-task map. The router is where the one genuinely valuable idea lives — &lt;strong&gt;routing by tier, not by vibes.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AiRouter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @param array&amp;lt;string, AiTask&amp;gt; $tasks */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;ModelGateway&lt;/span&gt; &lt;span class="nv"&gt;$gateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;AiCache&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnknownTaskException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$taskName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$raw&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BadOutputException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// one retry on the next tier up before giving up&lt;/span&gt;
            &lt;span class="nv"&gt;$raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;escalate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are doing real work here and each earns its place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Caching by (task, input) hash.&lt;/strong&gt; The same article body gets a headline suggestion once. Editors click these buttons repeatedly; without a cache you pay for every nervous re-click. This single layer was the biggest cost reduction we measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier-based escalation on bad output.&lt;/strong&gt; Cheap model first. If it returns garbage that fails &lt;code&gt;parse()&lt;/code&gt;, retry once on a stronger tier. Most cheap-model failures are formatting failures, and they don't repeat on the better model. You get cheap-model economics with premium-model reliability on the tail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gateway hides the provider.&lt;/strong&gt; &lt;code&gt;complete(tier, messages)&lt;/code&gt; is the entire surface the task sees. Whether &lt;code&gt;'cheap'&lt;/code&gt; maps to one provider this month and another next month is an ops decision, not a code change in 50 tasks.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The gateway: one seam for every provider
&lt;/h2&gt;

&lt;p&gt;The gateway is what makes provider diversity survivable. News work is bursty and rate limits are real, so you want the freedom to move a tier between providers without touching task code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ModelGateway&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tierConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tierConfig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$tier&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;          &lt;span class="c1"&gt;// provider + model + limits&lt;/span&gt;
        &lt;span class="nv"&gt;$provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProviderFactory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'provider'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;maxTokens&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'max_tokens'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff is operational, not architectural elegance for its own sake. When a provider degrades at 9 a.m. on an election morning — and it will — you change a config map, not a deployment. Keeping the model identity &lt;em&gt;out&lt;/em&gt; of the task and &lt;em&gt;in&lt;/em&gt; the tier config is the difference between a five-minute mitigation and a panic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality gates: cheap models lie confidently
&lt;/h2&gt;

&lt;p&gt;A specialized-task design tempts you toward cheap models everywhere, because each job is small. The trap is that small jobs still produce confidently wrong output. The defense is &lt;strong&gt;deterministic gates around non-deterministic output&lt;/strong&gt; — code, not another model, checks the boring constraints.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;validateHeadlines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$headlines&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$headlines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$len&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// length contract&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;mb_strtoupper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no ALL CAPS&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/!{2,}|\?{2,}/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no "!!!"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there's no model in that function. The expensive judgment ("is this a &lt;em&gt;good&lt;/em&gt; headline?") stays with the human editor. The cheap, mechanical judgment ("is this even a valid headline-shaped string?") is plain PHP that runs in microseconds and never hallucinates. Push every constraint you can express as code &lt;em&gt;out&lt;/em&gt; of the prompt and &lt;em&gt;into&lt;/em&gt; a gate. Prompts are for taste; code is for rules.&lt;/p&gt;

&lt;p&gt;The one place to spend a premium model deliberately is &lt;strong&gt;risk&lt;/strong&gt; — defamation, sensitive claims, anything where a wrong call has legal weight. That task should run on your strongest tier, never cache its "looks fine" verdict for long, and always present as &lt;em&gt;advisory&lt;/em&gt; to a human. AI flags; people decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asynchronous by default
&lt;/h2&gt;

&lt;p&gt;Editors will not wait four seconds for a button. Anything slower than roughly a second belongs in the background, with the result arriving when it's ready rather than blocking the save.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On save: enqueue, don't block.&lt;/span&gt;
&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ai.enrich'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'article_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'tasks'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'summary.make'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tag.extract'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'category.suggest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'seo.meta'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// A worker drains the queue and writes suggestions back as drafts&lt;/span&gt;
&lt;span class="c1"&gt;// the editor can accept or ignore — never auto-published.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules that saved us real pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Suggestions are drafts, never silent writes.&lt;/strong&gt; AI output lands in a "suggested" state. A human accepts it. The day you let a model write directly to the published field is the day you explain a hallucinated dateline to your editor-in-chief.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent jobs.&lt;/strong&gt; Queues retry. If &lt;code&gt;summary.make&lt;/code&gt; runs twice, the second run should overwrite the same suggestion slot, not create a duplicate. Key the write by &lt;code&gt;(article_id, task_name)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model the task contract first, the prompt second.&lt;/strong&gt; The interface outlives any specific model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate output inside the task.&lt;/strong&gt; Malformed responses are normal; treat them as control flow, not exceptions to your worldview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route by tier, cache by input.&lt;/strong&gt; These two together did more for cost and reliability than any prompt-engineering cleverness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep rules in code, taste in prompts.&lt;/strong&gt; Every constraint you can check deterministically is one the model can't violate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async, advisory, idempotent.&lt;/strong&gt; The newsroom trusts a tool that suggests and never surprises.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "50+" isn't a feature count to put on a slide. It's what falls out naturally once each AI job is small enough to have a single clear contract. Build the seam — task, registry, router, gateway, gate — and adding the fifty-first workflow is an afternoon, not a project.&lt;/p&gt;

&lt;p&gt;We've been refining this pattern in production news software at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;Alesta WEB&lt;/a&gt;, across publishers of very different sizes, and the lesson keeps repeating: the architecture, not the model, is what makes newsroom AI feel reliable instead of gimmicky.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>php</category>
      <category>news</category>
      <category>architecture</category>
    </item>
    <item>
      <title>KVKK, İYS, BİK: Turkish Software Compliance for Engineers (with PHP examples)</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sun, 21 Jun 2026 12:45:03 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/kvkk-iys-bik-turkish-software-compliance-for-engineers-with-php-examples-1ibo</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/kvkk-iys-bik-turkish-software-compliance-for-engineers-with-php-examples-1ibo</guid>
      <description>&lt;p&gt;If you build software that serves Turkish users, three acronyms will eventually land on your desk: &lt;strong&gt;KVKK&lt;/strong&gt;, &lt;strong&gt;İYS&lt;/strong&gt;, and &lt;strong&gt;BİK&lt;/strong&gt;. They are not optional "nice to have" features — they are legal obligations with real fines attached. Yet most engineering write-ups about them are written by lawyers, for lawyers, and stop exactly where the interesting part begins: the code.&lt;/p&gt;

&lt;p&gt;This is the article I wish I had when we first had to make 200+ production sites compliant. It's the engineer's view — what each rule actually requires from your application, and how to implement it cleanly in PHP. The examples are intentionally generic; adapt the storage and framework details to your own stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three you'll meet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Acronym&lt;/th&gt;
&lt;th&gt;Full name&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;th&gt;Who enforces it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KVKK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kişisel Verilerin Korunması Kanunu&lt;/td&gt;
&lt;td&gt;Personal data protection (Turkey's GDPR analogue)&lt;/td&gt;
&lt;td&gt;KVKK Authority (KVKK Kurumu)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;İYS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;İleti Yönetim Sistemi&lt;/td&gt;
&lt;td&gt;Commercial electronic messages (SMS/email/calls)&lt;/td&gt;
&lt;td&gt;Managed via a central national registry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BİK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basın İlan Kurumu&lt;/td&gt;
&lt;td&gt;Press/news site requirements &amp;amp; official announcements&lt;/td&gt;
&lt;td&gt;Basın İlan Kurumu (for news publishers)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;KVKK touches almost every app. İYS hits you the moment you send a marketing message. BİK is specific to news publishers. Let's take them in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. KVKK — personal data protection
&lt;/h2&gt;

&lt;p&gt;KVKK shares its DNA with GDPR, so if you've done GDPR work the concepts will feel familiar: lawful basis, explicit consent, data minimization, the right to erasure, breach notification, and registration with a central inventory (VERBİS) once you cross certain thresholds.&lt;/p&gt;

&lt;p&gt;The mistakes I see most often are not legal misreadings — they're engineering shortcuts. Three of them matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate consent from the action
&lt;/h3&gt;

&lt;p&gt;A checkbox that says "I accept the terms &lt;strong&gt;and&lt;/strong&gt; consent to marketing" bundles two different lawful bases. KVKK wants &lt;strong&gt;explicit, specific, unbundled&lt;/strong&gt; consent. In practice that means storing each consent as its own record with enough metadata to prove it later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each consent is its own row — never a single boolean on the user.&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;recordConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$granted&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'INSERT INTO consent_log
            (user_id, purpose, granted, ip, user_agent, created_at)
         VALUES (:uid, :purpose, :granted, :ip, :ua, :now)'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'uid'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'purpose'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// e.g. 'marketing_email', 'analytics'&lt;/span&gt;
        &lt;span class="s1"&gt;'granted'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$granted&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'ip'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'REMOTE_ADDR'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'ua'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'HTTP_USER_AGENT'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'now'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i:s'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of the &lt;code&gt;ip&lt;/code&gt;, &lt;code&gt;user_agent&lt;/code&gt;, and timestamp is not surveillance — it's that when someone asks "did this user actually consent, and when?", you can answer with evidence instead of a shrug. Consent is also revocable, so you append a new &lt;code&gt;granted = 0&lt;/code&gt; row rather than mutating the old one. The history is the proof.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cookie banner has to actually block scripts
&lt;/h3&gt;

&lt;p&gt;A banner that loads Google Analytics and the Meta pixel &lt;em&gt;before&lt;/em&gt; the user clicks "accept" is theatre. Under KVKK (as under GDPR) non-essential trackers must not fire until consent exists. The cleanest implementation is to gate the script tags server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;trackingScripts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'analytics'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// nothing loads, no third-party calls&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;script src="/js/analytics.js" defer&amp;gt;&amp;lt;/script&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your analytics is purely first-party and aggregated (no cross-site identifiers, no PII), you have a much easier compliance story — which is one practical reason a lot of Turkish products lean toward self-hosted, first-party analytics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Erasure means erasure (but keep the legal minimum)
&lt;/h3&gt;

&lt;p&gt;The right to be forgotten collides with other obligations: you may be legally required to keep invoice and transaction records for years. The resolution is &lt;strong&gt;anonymization&lt;/strong&gt;, not blind deletion. Strip the identifying fields, keep the financially/legally required skeleton.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;eraseUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Anonymize, don't orphan: invoices must survive for tax law.&lt;/span&gt;
    &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"UPDATE users SET
            name='[deleted]', email=CONCAT('deleted_', id, '@invalid'),
            phone=NULL, address=NULL, anonymized_at=:now
         WHERE id=:id"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i:s'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Hard-delete things with no retention requirement.&lt;/span&gt;
    &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DELETE FROM consent_log WHERE user_id=:id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decide your retention periods deliberately, document them, and make them queryable. "We delete logs after N days" is a sentence you want to back with a cron job, not a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. İYS — the commercial message registry
&lt;/h2&gt;

&lt;p&gt;İYS is the part that surprises foreign engineers. In Turkey there is a &lt;strong&gt;central national registry&lt;/strong&gt; for commercial electronic message consent. Before you send a marketing SMS, email, or call to someone, their consent must exist in that registry — and your own database agreeing isn't enough on its own.&lt;/p&gt;

&lt;p&gt;The engineering consequence: &lt;strong&gt;never send marketing straight from your app's consent table.&lt;/strong&gt; You check İYS first. Transactional messages (order confirmations, password resets, shipping updates) are exempt — but the line between "transactional" and "marketing" is exactly where people get fined, so be conservative.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;canSendMarketing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 'channel' is one of: MESAJ (SMS), EPOSTA (email), ARAMA (call)&lt;/span&gt;
    &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;iysLookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// calls the registry/integrator API&lt;/span&gt;

    &lt;span class="c1"&gt;// Only an explicit, active opt-in counts.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'ONAY'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// vs 'RET' (rejected) or 'ALICI_YOK' (unknown)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sendCampaign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$recipients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$recipients&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;canSendMarketing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;logSkipped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no_iys_consent'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// skipping is cheaper than a fine&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical notes from running this at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sync both directions.&lt;/strong&gt; When a user opts in or out in your UI, push that change up to the registry. When the registry changes (a user opts out there), pull it down. A nightly reconciliation job catches drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache lookups, but not forever.&lt;/strong&gt; Hammering the lookup API per-recipient on a large campaign is slow and rude. A short-TTL cache (hours, not weeks) keeps you fast without letting stale "ONAY" values send messages to someone who opted out this morning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. BİK — for news publishers
&lt;/h2&gt;

&lt;p&gt;If you don't run a news site, skip this section. If you do, BİK compliance is what stands between you and being eligible for official announcements (resmî ilan) and the credibility that comes with it.&lt;/p&gt;

&lt;p&gt;BİK's requirements are more editorial than algorithmic, but several do translate into application features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mandatory imprint (künye):&lt;/strong&gt; publisher identity, responsible editor, contact, and address must be present and reachable. This is a structured page, not a footer afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source attribution (mahreç):&lt;/strong&gt; agency-sourced content must be labelled with its origin. If you ingest from news agencies, carry the source field through your pipeline to the rendered article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content cadence &amp;amp; originality:&lt;/strong&gt; a minimum flow of original editorial content, which in practice means your CMS needs solid authorship, scheduling, and audit metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Archive integrity:&lt;/strong&gt; published pieces should remain accessible and stable at their URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The imprint and source-attribution pieces are the ones that bite teams late, because they're easy to bolt on at the end and easy to get subtly wrong. Model them as first-class fields from the start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Carry the source through to render — don't lose it in the import step.&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;renderArticleMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;div class="article-meta"&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;span class="author"&amp;gt;'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'author_name'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/span&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'source_agency'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;span class="source"&amp;gt;Kaynak: '&lt;/span&gt;
              &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'source_agency'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/span&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// mahreç&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;time datetime="'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published_at'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'"&amp;gt;'&lt;/span&gt;
          &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;e&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;formatTr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published_at'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/time&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;/div&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of requirement that pushed our news CMS work toward treating source, author, and publish metadata as non-negotiable columns rather than optional extras — it's far cheaper than retrofitting attribution across an archive later.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist you can paste into a ticket
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; consent stored per-purpose, with timestamp + evidence, revocable as an append&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; non-essential trackers gated server-side, fire only after consent&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; erasure = anonymize-and-retain-legal-minimum, with documented retention periods&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;KVKK:&lt;/strong&gt; VERBİS registration checked against your data-controller thresholds&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;İYS:&lt;/strong&gt; registry lookup before every marketing send; transactional messages clearly separated&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;İYS:&lt;/strong&gt; two-way sync (UI ⇄ registry) + nightly reconciliation + short-TTL cache&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;BİK:&lt;/strong&gt; structured imprint (künye) page, reachable&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;BİK:&lt;/strong&gt; source attribution (mahreç) carried end-to-end from import to render&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;BİK:&lt;/strong&gt; stable article URLs and accessible archive&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;None of this is exotic engineering. It's mostly the discipline of modeling consent and provenance as data you can prove, not state you assume. Do it at the schema level on day one and compliance becomes a property of the system. Bolt it on at the end and it becomes a migration you'll dread.&lt;/p&gt;

&lt;p&gt;We've shipped this pattern across 200+ Turkish production sites at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;Alesta WEB&lt;/a&gt;, and the single biggest lesson is the boring one: &lt;strong&gt;make consent and source first-class data, and the legal requirements mostly take care of themselves.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is general engineering guidance, not legal advice — confirm specifics for your situation with a qualified professional.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>legal</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>How We Reduced LLM Costs by 95%: Cache + Batch + Cascade in PHP</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sat, 13 Jun 2026 11:10:36 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/how-we-reduced-llm-costs-by-95-cache-batch-cascade-in-php-1ok6</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/how-we-reduced-llm-costs-by-95-cache-batch-cascade-in-php-1ok6</guid>
      <description>&lt;h1&gt;
  
  
  How We Reduced LLM Costs by 95%: Cache + Batch + Cascade in PHP
&lt;/h1&gt;

&lt;p&gt;We build news software — a content platform used by more than 200 publishers at Alesta WEB. Once we wired language models into the newsroom workflow (headline suggestions, summaries, SEO fields, tag extraction, draft scaffolding), something predictable happened: the AI bill started growing faster than the feature list.&lt;/p&gt;

&lt;p&gt;The naive version of "add AI" is a thin wrapper around one expensive frontier model, called fresh on every request. It works in a demo. In production, across thousands of articles a day, it's a slow way to set money on fire.&lt;/p&gt;

&lt;p&gt;This is the architecture we settled on after eighteen months of running it. Three layers — &lt;strong&gt;cache&lt;/strong&gt;, &lt;strong&gt;batch&lt;/strong&gt;, &lt;strong&gt;cascade&lt;/strong&gt; — plus the quality gates that make the cheap layers safe to rely on. The result was roughly a 95% reduction in per-task cost versus the naive "frontier-model-only, no cache" baseline, with no measurable drop in editorial quality.&lt;/p&gt;

&lt;p&gt;The code is PHP, because the platform is PHP. The ideas are language-agnostic.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Naive Approach (and What It Costs)
&lt;/h2&gt;

&lt;p&gt;Here's the version almost everyone ships first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generateHeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$articleBody&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'You write concise news headlines.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$articleBody&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'choices'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is wrong with this code. The problem is the &lt;em&gt;usage pattern&lt;/em&gt; around it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same wire-service story gets posted by dozens of sites, so we generate near-identical headlines over and over.&lt;/li&gt;
&lt;li&gt;Editors regenerate three or four times to compare options.&lt;/li&gt;
&lt;li&gt;A frontier model is doing work — extracting tags, normalizing a category — that a model costing a fraction as much would do just as well.&lt;/li&gt;
&lt;li&gt;Every call is synchronous and real-time, even when nothing about the task is urgent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you multiply "fresh frontier call every time" by real newsroom volume, the per-article AI cost lands somewhere that makes the CFO ask uncomfortable questions. Each of the three layers below attacks one of those waste sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Layer 1 — Cache: What to Cache, and What Not To
&lt;/h2&gt;

&lt;p&gt;The single biggest win is the most boring one: &lt;strong&gt;don't ask the same question twice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A large share of LLM calls in a news system are functionally identical. The key insight is that the cache key should be derived from the &lt;em&gt;meaningful&lt;/em&gt; inputs — the task type, the model, the prompt template version, and a normalized hash of the content — not from the raw request object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cachedComplete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$compute&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'llm:%s:%s:%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="no"&gt;PROMPT_VERSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;// bump to invalidate on prompt change&lt;/span&gt;
        &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'xxh128'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hit&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// ~0 cost, sub-millisecond&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// the actual API call&lt;/span&gt;
    &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ttlFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// collapse whitespace, strip volatile boilerplate so trivially&lt;/span&gt;
    &lt;span class="c1"&gt;// different inputs map to the same key&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\s+/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the cache engine you pick (we use Redis, but a database table works):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version the prompt in the key.&lt;/strong&gt; When you change a prompt template, you &lt;em&gt;want&lt;/em&gt; every cached answer for that task to become a miss. Putting a &lt;code&gt;PROMPT_VERSION&lt;/code&gt; constant into the key turns prompt edits into a clean, instant invalidation instead of a stale-output bug you chase for a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know what not to cache.&lt;/strong&gt; Anything personalized, anything real-time, anything where two identical inputs should legitimately produce different outputs (a "give me a fresh alternative" button) must bypass the cache. We mark those tasks explicitly rather than relying on TTL alone.&lt;/p&gt;

&lt;p&gt;In our workload the cache hit rate sits around 60–70%, mostly because syndicated content repeats across sites. That one layer alone removes well over half the spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Layer 2 — Batch APIs: Trade Latency for Money
&lt;/h2&gt;

&lt;p&gt;A surprising amount of LLM work in a newsroom is &lt;strong&gt;not time-sensitive.&lt;/strong&gt; Nightly re-tagging of the archive. Generating summaries for the previous day's articles. Backfilling SEO descriptions on older content. None of it needs an answer in 800 milliseconds.&lt;/p&gt;

&lt;p&gt;The major providers offer batch endpoints that run asynchronously — you submit a file of requests, and within a window (typically up to 24 hours) you get the results back, at roughly half the price of the synchronous API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Collect non-urgent jobs into a single batch submission&lt;/span&gt;
&lt;span class="nv"&gt;$lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pendingJobs&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lines&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'custom_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'method'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'url'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/v1/chat/completions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'model'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$job&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$batchFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uploadJsonl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lines&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nv"&gt;$batch&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'input_file_id'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$batchFile&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'endpoint'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/v1/chat/completions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'completion_window'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'24h'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// A worker polls for completion and writes results back by custom_id&lt;/span&gt;
&lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'poll_batch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'batch_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$batch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The discipline this forces is healthy: you have to classify each task as &lt;em&gt;interactive&lt;/em&gt; (editor is waiting) or &lt;em&gt;deferred&lt;/em&gt; (a cron job can handle it tonight). Once we did that audit, far more work turned out to be deferrable than we expected. Roughly a quarter of our remaining spend — after caching — moved onto batch pricing for a flat ~50% discount on that slice.&lt;/p&gt;

&lt;p&gt;A caveat: batch pricing differs by provider, and so does the completion window and the failure behavior. Build your batch layer behind an interface so the provider is swappable, and always handle partial failures — a batch of 5,000 requests will occasionally return 4,997.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Layer 3 — Cascade Routing: Match the Model to the Task
&lt;/h2&gt;

&lt;p&gt;The last layer is the one people resist, because it feels like cutting corners. It isn't — it's refusing to pay frontier prices for kindergarten work.&lt;/p&gt;

&lt;p&gt;Not every task needs the smartest model. Extracting tags from a story, mapping a category, cleaning up whitespace, classifying sentiment — small, cheap models handle these perfectly. Reserve the expensive model for genuinely hard generation: nuanced summaries, editorial rewriting, anything where a mistake is visible to readers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;TASK_TIER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'tag_extraction'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'category_map'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'sentiment'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cheap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'summary'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'headline'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'editorial_rewrite'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'frontier'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;TIER_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'cheap'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'mid'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'frontier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'the-strongest-model-you-trust'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;modelFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;TIER_MODEL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;TASK_TIER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'mid'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We run six providers behind one interface (the editorial team never sees which one answered), which means cascade routing can also fail over: if a cheap model's output fails a quality gate, the task is automatically re-run one tier up. That gives you the cost of the cheap tier on the 90%+ of cases it handles well, and the safety of the expensive tier on the cases it doesn't.&lt;/p&gt;

&lt;p&gt;Cascade routing is what takes you from "big savings" to "almost free for the easy majority."&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Quality Gates: Keeping Cheap Models Honest
&lt;/h2&gt;

&lt;p&gt;Cascade routing only works if you can &lt;em&gt;detect&lt;/em&gt; when a cheap model got it wrong — otherwise you're trading money for garbage. Quality gates are cheap, deterministic checks that run on the output before it's accepted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;passesGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s1"&gt;'headline'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
                      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;looksTruncated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

        &lt;span class="s1"&gt;'summary'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
                      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;sentenceCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="s1"&gt;'tag_extraction'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;isValidJsonArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

        &lt;span class="k"&gt;default&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these call an LLM. They're string length, format validity, structure checks — the kind of thing that costs nothing and catches the most common cheap-model failures (truncation, wrong format, empty output). When a gate fails, the cascade re-runs the task one tier up and logs it. If a particular task fails its gate too often, that's your signal to move it up a tier permanently.&lt;/p&gt;

&lt;p&gt;This is the piece that makes the whole architecture trustworthy. Without gates, "use a cheaper model" is a gamble. With gates, it's a measured decision with an automatic safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A Cost Dashboard You Actually Look At
&lt;/h2&gt;

&lt;p&gt;You can't optimize what you don't measure. We log every LLM call with four fields: task, tier, whether it was a cache hit, and the token counts. That's enough to answer the only questions that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which task is costing the most? (Usually the surprise here is a "cheap" task being called a million times.)&lt;/li&gt;
&lt;li&gt;What's our real cache hit rate, per task?&lt;/li&gt;
&lt;li&gt;How often is the cascade escalating — and which tasks?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A weekly rollup of per-task economics turns cost control from a panic ("the bill doubled!") into a routine ("tag extraction escalation rate crept up, the prompt drifted, fix the template"). The dashboard is boring on purpose. Boring means in control.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The Numbers, After Eighteen Months
&lt;/h2&gt;

&lt;p&gt;Against the naive baseline (one frontier model, every call fresh and synchronous), the layers compound:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache&lt;/strong&gt; removes ~60–70% of calls outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch&lt;/strong&gt; takes ~50% off a meaningful slice of what remains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cascade&lt;/strong&gt; routes the easy majority of the rest to models costing a fraction of frontier prices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stacked, that lands at roughly &lt;strong&gt;95% lower per-task cost&lt;/strong&gt; for the same workload — and, because cache hits are instant and cheap models are fast, the &lt;em&gt;median&lt;/em&gt; latency for AI features actually improved. Cheaper and faster, which is not the trade-off people expect when they hear "we cut the AI budget."&lt;/p&gt;

&lt;p&gt;The editorial quality held because the expensive model still does all the work that's actually hard; we just stopped paying it to do the easy work.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. What's Next: Prompt Caching
&lt;/h2&gt;

&lt;p&gt;The newest lever we're rolling out is provider-side &lt;strong&gt;prompt caching&lt;/strong&gt; — where a long, stable system prompt (style guide, formatting rules, examples) is cached on the provider's side and billed at a steep discount on repeat calls. For a news system with a large, rarely-changing editorial style prompt prepended to thousands of calls, that's a natural fit on top of the three layers above.&lt;/p&gt;

&lt;p&gt;The throughline across all of it is the same: &lt;strong&gt;a language model is a power tool, not a default.&lt;/strong&gt; Cache the repeats, defer what isn't urgent, route easy work to cheap models, and verify cheap output with checks that cost nothing. Do that, and AI features stop being a line item that scares the finance team and go back to being what they should be — a feature.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We build news software used by 200+ publishers — agency integration, AI-assisted editorial workflows, native mobile apps, and subscription infrastructure. More on the platform at &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;alestaweb.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>php</category>
      <category>performance</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Schema.org NewsArticle: A Complete Implementation Guide for Google News in 2026</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sun, 31 May 2026 23:00:47 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/schemaorg-newsarticle-a-complete-implementation-guide-for-google-news-in-2026-5e7g</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/schemaorg-newsarticle-a-complete-implementation-guide-for-google-news-in-2026-5e7g</guid>
      <description>&lt;h1&gt;
  
  
  Schema.org NewsArticle: A Complete Implementation Guide for Google News in 2026
&lt;/h1&gt;

&lt;p&gt;Most news sites that fail to get into Google News don't fail because of their content. They fail because their structured data is wrong, incomplete, or missing — and nobody told them, because the failure is silent. No error, no email, just no traffic.&lt;/p&gt;

&lt;p&gt;This is a field guide to getting &lt;code&gt;NewsArticle&lt;/code&gt; structured data right. It comes from running it across 200+ production news portals over the last 18 months at Alesta WEB, where a single malformed &lt;code&gt;datePublished&lt;/code&gt; field can quietly drop a story out of the news index for a publisher who has no idea why.&lt;/p&gt;

&lt;p&gt;I'll cover every field that matters, the publisher markup that ties it together, the news sitemap's brutal 48-hour window, how AMP and canonical interact in 2026, IndexNow for instant Bing/Yandex pickup, and the validation pipeline we run before anything ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why Structured Data Matters Beyond Google
&lt;/h2&gt;

&lt;p&gt;It's tempting to think of &lt;code&gt;NewsArticle&lt;/code&gt; JSON-LD as "the thing Google wants." It is, but that framing undersells it.&lt;/p&gt;

&lt;p&gt;Structured data is now the machine-readable contract for your content across the entire discovery layer: Google News and Top Stories, Bing News, the knowledge graphs that feed voice assistants, and — increasingly — the LLMs that summarize current events. When a model is asked "what happened in city X today," it leans on sources whose articles are cleanly typed, dated, and attributed. Ambiguous HTML doesn't get parsed reliably. Clean JSON-LD does.&lt;/p&gt;

&lt;p&gt;So the payoff isn't one channel. Getting &lt;code&gt;NewsArticle&lt;/code&gt; right is the cheapest single thing you can do to make a story legible to every automated consumer at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. NewsArticle: Every Field That Matters
&lt;/h2&gt;

&lt;p&gt;Here is a complete, valid &lt;code&gt;NewsArticle&lt;/code&gt; block. I'll annotate the fields that people get wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@context&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://schema.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NewsArticle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mainEntityOfPage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WebPage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/news/city-council-approves-budget&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;City Council Approves 2026 Budget After Three-Hour Debate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/img/budget-16x9.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/img/budget-4x3.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/img/budget-1x1.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;datePublished&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-06-01T08:30:00+03:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dateModified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-06-01T09:15:00+03:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;author&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Person&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ayşe Yılmaz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/author/ayse-yilmaz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;publisher&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NewsMediaOrganization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Example Daily&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;logo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ImageObject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/logo-600x60.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;width&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;height&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The council passed the budget 7-4 after debate over transit funding.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;articleSection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inLanguage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields people break, in order of how often I see them broken:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;datePublished&lt;/code&gt; without a timezone.&lt;/strong&gt; This is the number one cause of silent failure. &lt;code&gt;"2026-06-01T08:30:00"&lt;/code&gt; is ambiguous. Google may interpret it as UTC, your server may mean local time, and the gap can push a story outside the freshness window or make it look hours old at publication. Always include the offset: &lt;code&gt;+03:00&lt;/code&gt;, &lt;code&gt;Z&lt;/code&gt;, whatever is correct — but never omit it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dateModified&lt;/code&gt; going backwards or matching publish exactly forever.&lt;/strong&gt; If you genuinely edit an article, update &lt;code&gt;dateModified&lt;/code&gt;. But don't fake it by bumping it on every page load — Google notices articles whose modification date changes without content changing, and it erodes trust. Set it when the content actually changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;headline&lt;/code&gt; over 110 characters.&lt;/strong&gt; Google truncates and may ignore long headlines for Top Stories. Keep it under 110 characters. This is a hard, documented limit, not a suggestion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;image&lt;/code&gt; with a single small image.&lt;/strong&gt; Provide multiple aspect ratios (16x9, 4x3, 1x1) at a minimum width of 1200px. A 600px-wide thumbnail disqualifies you from large image treatment in Top Stories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;author.url&lt;/code&gt; missing.&lt;/strong&gt; An author object with just a &lt;code&gt;name&lt;/code&gt; is weak. Give every author a real, crawlable profile page and link it via &lt;code&gt;url&lt;/code&gt;. This is also an E-E-A-T signal — the author needs to be a verifiable entity, not a string.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. NewsMediaOrganization: The Publisher Half
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;publisher&lt;/code&gt; inside each article should be a &lt;code&gt;NewsMediaOrganization&lt;/code&gt;, and that organization should also exist as a standalone entity on your home page or a dedicated &lt;code&gt;/about&lt;/code&gt; page. The two reinforce each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@context&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://schema.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NewsMediaOrganization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Example Daily&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;logo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ImageObject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/logo-600x60.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;width&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;height&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sameAs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://twitter.com/exampledaily&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.facebook.com/exampledaily&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;diversityPolicy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/diversity-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ethicsPolicy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/ethics-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;masthead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/masthead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;logo&lt;/code&gt; constraints trip people up: it must be a raster format (PNG/JPG, not SVG), no wider than 600px, and no taller than 60px. The &lt;code&gt;ethicsPolicy&lt;/code&gt;, &lt;code&gt;diversityPolicy&lt;/code&gt;, and &lt;code&gt;masthead&lt;/code&gt; properties are optional but they are genuine trust signals for news specifically — having real pages behind them helps with Google News eligibility reviews.&lt;/p&gt;

&lt;p&gt;One rule we enforce in production: the publisher &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;logo&lt;/code&gt; must be byte-identical across every article and the organization entity. Inconsistency here — "Example Daily" in one place, "ExampleDaily" in another — is read as two different publishers.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Sitemap-news.xml: The 48-Hour Window
&lt;/h2&gt;

&lt;p&gt;A news sitemap is not a regular sitemap. It only lists articles published in the &lt;strong&gt;last 48 hours&lt;/strong&gt;, and it carries extra &lt;code&gt;&amp;lt;news:news&amp;gt;&lt;/code&gt; metadata.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;urlset&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.sitemaps.org/schemas/sitemap/0.9"&lt;/span&gt;
        &lt;span class="na"&gt;xmlns:news=&lt;/span&gt;&lt;span class="s"&gt;"http://www.google.com/schemas/sitemap-news/0.9"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://example.com/news/city-council-approves-budget&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;news:news&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;news:publication&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;news:name&amp;gt;&lt;/span&gt;Example Daily&lt;span class="nt"&gt;&amp;lt;/news:name&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;news:language&amp;gt;&lt;/span&gt;en&lt;span class="nt"&gt;&amp;lt;/news:language&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/news:publication&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;news:publication_date&amp;gt;&lt;/span&gt;2026-06-01T08:30:00+03:00&lt;span class="nt"&gt;&amp;lt;/news:publication_date&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;news:title&amp;gt;&lt;/span&gt;City Council Approves 2026 Budget After Three-Hour Debate&lt;span class="nt"&gt;&amp;lt;/news:title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/news:news&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/urlset&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make or break this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop articles older than 48 hours.&lt;/strong&gt; Leaving stale URLs in the news sitemap is a quality signal against you. The sitemap must be generated dynamically and prune itself. We regenerate ours on publish and on a short cron, never as a static file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;news:publication_date&lt;/code&gt; must match &lt;code&gt;datePublished&lt;/code&gt;.&lt;/strong&gt; Same timezone, same value. If your JSON-LD says one time and your sitemap says another, you've told Google two contradictory things about the same article.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. AMP vs Canonical in 2026
&lt;/h2&gt;

&lt;p&gt;This used to be a real decision. In 2026 it mostly isn't.&lt;/p&gt;

&lt;p&gt;Google dropped the AMP requirement for Top Stories back in 2021, and Core Web Vitals became the actual gate. If your canonical pages are fast — good LCP, low CLS, responsive — you do &lt;strong&gt;not&lt;/strong&gt; need AMP to appear in Top Stories. We removed AMP from most sites and saw no ranking loss, plus we deleted an entire parallel rendering path and its bugs.&lt;/p&gt;

&lt;p&gt;The honest guidance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default: ship fast canonical HTML, no AMP.&lt;/strong&gt; One source of truth, less to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep AMP only if&lt;/strong&gt; you have a specific downstream consumer that still requires it, or your canonical pages genuinely can't hit good Core Web Vitals and you can't fix the root cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you do serve both, the canonical page must point to itself with &lt;code&gt;rel="canonical"&lt;/code&gt;, and the AMP page must point back to the canonical with &lt;code&gt;rel="canonical"&lt;/code&gt;. Getting that backwards is a common way to deindex your real pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. IndexNow: Instant Pickup on Bing and Yandex
&lt;/h2&gt;

&lt;p&gt;Google still crawls on its own schedule. Bing and Yandex, however, accept a push: IndexNow lets you notify them the instant an article goes live, instead of waiting for a crawl.&lt;/p&gt;

&lt;p&gt;The setup is trivial. Host a key file at your root, then POST URLs on publish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Host the key at https://example.com/&amp;lt;key&amp;gt;.txt containing just the key&lt;/span&gt;
&lt;span class="c"&gt;# 2. On every publish, ping:&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.indexnow.org/indexnow?url=https://example.com/news/city-council-approves-budget&amp;amp;key=&amp;lt;key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or submit a batch as JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-key-here"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"urlList"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/news/article-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/news/article-2"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a news site where being first matters, the minutes you save on Bing/Yandex indexing are real. We wire IndexNow into the same publish hook that regenerates the news sitemap — one event, both actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A Validation Pipeline That Catches Errors Before Deploy
&lt;/h2&gt;

&lt;p&gt;Hand-checking structured data doesn't scale past a few articles. Across hundreds of sites it has to be automated, and it has to run &lt;strong&gt;before&lt;/strong&gt; content reaches users.&lt;/p&gt;

&lt;p&gt;What our pipeline checks on every article render in staging:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD parses.&lt;/strong&gt; A trailing comma silently disables the whole block. Parse it as JSON; fail the build if it throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Required fields present.&lt;/strong&gt; &lt;code&gt;headline&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;datePublished&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;publisher&lt;/code&gt; — assert each exists and is non-empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;datePublished&lt;/code&gt; has a timezone offset.&lt;/strong&gt; Regex-reject any ISO timestamp without &lt;code&gt;Z&lt;/code&gt; or &lt;code&gt;±HH:MM&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;headline&lt;/code&gt; ≤ 110 characters.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;image&lt;/code&gt; width ≥ 1200px&lt;/strong&gt; (check the actual asset, not just the URL).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publisher name/logo match&lt;/strong&gt; the canonical organization entity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap date == JSON-LD date&lt;/strong&gt; for the same URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A minimal version of check 3, the highest-value one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;assertHasTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$iso&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/(Z|[+\-]\d{2}:\d{2})$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$iso&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"datePublished missing timezone: &lt;/span&gt;&lt;span class="nv"&gt;$iso&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beyond the build, use Google's Rich Results Test and the schema.org validator on a sample of live URLs weekly. The build catches structural errors; the external validators catch the rules Google changes without announcing.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Passing vs Failing: A Side-by-Side
&lt;/h2&gt;

&lt;p&gt;Failing markup — and why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NewsArticle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"City Council Approves The 2026 Municipal Budget After A Long And Contentious Three-Hour Public Debate Session"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"datePublished"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-01 08:30:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ayşe Yılmaz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/thumb.jpg"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four problems: headline over 110 chars, &lt;code&gt;datePublished&lt;/code&gt; with no timezone and a space instead of &lt;code&gt;T&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt; as a bare string instead of a &lt;code&gt;Person&lt;/code&gt; object with a URL, and a single thumbnail-sized image. Each one individually can keep this out of Top Stories.&lt;/p&gt;

&lt;p&gt;Passing markup is the full block from section 2: typed author with a profile URL, ISO-8601 date with offset, headline under the limit, and multiple large images. The difference between these two blocks is the difference between being indexed and being invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;NewsArticle&lt;/code&gt; structured data isn't glamorous, but for a news publisher it's the highest-leverage SEO work there is. The content is yours to write; the markup is what makes machines trust it.&lt;/p&gt;

&lt;p&gt;Get the five required fields right, give every date a timezone, keep your news sitemap pruned to 48 hours, push to IndexNow on publish, and validate before you deploy. Do that consistently and the silent failures stop being silent — they stop happening.&lt;/p&gt;

&lt;p&gt;If you run a single site, do this by hand once and template it. If you run many, build the validation pipeline first. We learned the hard way that across 200+ portals, the cost of one wrong &lt;code&gt;datePublished&lt;/code&gt; format multiplied by every article is a traffic problem you'll spend weeks tracing back to one missing &lt;code&gt;+03:00&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>schema</category>
      <category>webdev</category>
      <category>news</category>
    </item>
    <item>
      <title>Haber yazilimi, haber scripti, haber sistemi: ayni urun, uc ayri arama niyeti</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Tue, 26 May 2026 00:50:55 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/haber-yazilimi-haber-scripti-haber-sistemi-ayni-urun-uc-ayri-arama-niyeti-24l7</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/haber-yazilimi-haber-scripti-haber-sistemi-ayni-urun-uc-ayri-arama-niyeti-24l7</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Turkce yazilim pazarinda &lt;strong&gt;haber yazilimi&lt;/strong&gt;, &lt;strong&gt;haber scripti&lt;/strong&gt; ve &lt;strong&gt;haber sistemi&lt;/strong&gt; terimleri cogunlukla ayni urunu tanimlar: bir haber portalini yoneten icerik yonetim sistemi. Vurgu farklidir, paket icerik ayni olabilir. Bu yazi uc terim arasindaki ince farki, yayincilarin hangisini ne zaman aradigini ve modern bir haber CMS'inde olmasi gereken ozellikleri anlatir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neden bu kadar cok terim?
&lt;/h2&gt;

&lt;p&gt;Turkce arama davranisi yabanci dillerden farklidir. Ingilizcede "news CMS" veya "news publishing platform" dominanttir. Turkcede ayni urun icin uc ayri Google sorgusu yapilir:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sorgu&lt;/th&gt;
&lt;th&gt;Aylik Trafik (kabaca)&lt;/th&gt;
&lt;th&gt;Niyet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;haber scripti&lt;/td&gt;
&lt;td&gt;~2.000&lt;/td&gt;
&lt;td&gt;Kod tabanli paket arayan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;haber yazilimi&lt;/td&gt;
&lt;td&gt;~1.500&lt;/td&gt;
&lt;td&gt;Butuncul cozumu arayan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;haber sistemi&lt;/td&gt;
&lt;td&gt;~700&lt;/td&gt;
&lt;td&gt;Kurumsal yayin sistemi arayan&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Uc terim de pratikte ayni tedarikciye yonelir, ama yayincilar urunu farkli adlandirir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uc terim arasindaki ince fark
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Haber scripti
&lt;/h3&gt;

&lt;p&gt;Mevcut bir altyapidan baska bir altyapiya gecmek isteyen yayincilarin terimidir. "X haber scripti satin aldim, ozellestirdim, sunucuya yukledim" tarzi kullanim. PHP/MySQL ile yazilmis, kod tabanini gormek, ozellestirmek isteyen IT departmanlarinin/teknik sahiplerin tercihi.&lt;/p&gt;

&lt;h3&gt;
  
  
  Haber yazilimi
&lt;/h3&gt;

&lt;p&gt;Yeni bir haber portali kurmak isteyen yayincilarin terimidir. Sadece kod degil, paket (mobil uygulama, sunucu, kurulum, destek, egitim) dahil. Genelde son musteri (yayinci) terminolojisi.&lt;/p&gt;

&lt;h3&gt;
  
  
  Haber sistemi
&lt;/h3&gt;

&lt;p&gt;Cok yazarli, cok rolu, surec yonetimi gerektiren kurumsal yayinlarin terimidir. Editor onayi, yetkilendirme matrisi, raporlama, istatistik bekleyen yayinlar "haber sistemi" der. Buyuk yayin kuruluslari ve kurumsal sirketler bu terimi tercih eder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hangisini ne zaman aramaliyiz?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Yeni bir yayin acacaksam        -&amp;gt; haber yazilimi (butuncul paket)
Mevcut yayinda kod degistirecegim -&amp;gt; haber scripti (kod tabani)
Kurumsal yayin yonetecegim      -&amp;gt; haber sistemi (operasyonel iskelet)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uc senaryoda da musterinin aradigi ozelliklerin %90'i ayni tedarikci tarafindan ayni paketle saglanir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modern bir haber CMS'inde olmasi beklenen ozellikler
&lt;/h2&gt;

&lt;p&gt;Yayincilarin 2026 sonrasinda bekledigi temel modullerin listesi:&lt;/p&gt;

&lt;h3&gt;
  
  
  Icerik
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cok tipli icerik&lt;/strong&gt;: haber, video, galeri, makale, etkinlik, podcast, biyografi, vefat ilani&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ajans entegrasyonu&lt;/strong&gt;: AA, DHA, IHA, ANKA, THA, HIBYA, IGFA, BHA gibi haber ajanslarindan otomatik haber cekme&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI ile icerik uretimi&lt;/strong&gt;: GPT, Gemini, Claude, DeepSeek, Groq gibi modeller ile haber yazma, baslik onerme, ozetleme&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  SEO ve gorunurluk
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google News uyumu&lt;/strong&gt;: sitemap-news.xml, NewsArticle Schema&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AMP destegi&lt;/strong&gt;: Hizli mobil gorunum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Overview optimizasyonu&lt;/strong&gt;: FAQPage Schema, llms.txt, ai-sitemap.xml&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IndexNow API&lt;/strong&gt;: Anlik indeksleme (Bing + Yandex)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Mobil
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native uygulama&lt;/strong&gt;: iOS + Android (kurulum + uygulama magazasi yayini dahil)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PWA destegi&lt;/strong&gt;: Offline okuma&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push bildirim&lt;/strong&gt;: OneSignal veya benzeri&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Operasyonel
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cok yazarli panel&lt;/strong&gt;: Editor, yazar, yonetici rolleri&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abonelik / Paywall&lt;/strong&gt;: Premium icerik kapatma (iyzico, PayTR vb. odeme)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reklam yonetimi&lt;/strong&gt;: AdSense, banner pozisyonlari&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Newsletter / E-bulten&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Performans ve guvenlik
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache katmanli&lt;/strong&gt;: Redis + dosya cache + CDN&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gorsel optimizasyon&lt;/strong&gt;: WebP otomatik donusum, lazy loading&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guvenlik&lt;/strong&gt;: CSRF, XSS, IP banlama, rate limiting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSL/TLS&lt;/strong&gt;: Let's Encrypt veya benzeri&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Karar matrisi
&lt;/h2&gt;

&lt;p&gt;Yayinciliga baslayacak biri tedarikci sectiginde bakmasi gerekenler:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ihtiyac&lt;/th&gt;
&lt;th&gt;Bakilacak ozellik&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Yerel haber portali&lt;/td&gt;
&lt;td&gt;Haber ajansi entegrasyonu + reklam&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kurumsal yayin&lt;/td&gt;
&lt;td&gt;Cok yazar yonetimi + raporlama&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yuksek trafik&lt;/td&gt;
&lt;td&gt;Cache + CDN + mobil&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium icerik&lt;/td&gt;
&lt;td&gt;Abonelik / Paywall modulu&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google News basvurusu&lt;/td&gt;
&lt;td&gt;NewsArticle Schema + sitemap-news&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI ile icerik uretimi&lt;/td&gt;
&lt;td&gt;Coklu AI saglayici destegi&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Lisans modelleri
&lt;/h2&gt;

&lt;p&gt;Pazarda iki ana lisans modeli vardir:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tek seferlik lisans&lt;/strong&gt;: Bir kerelik odeme, kaynak kod erisimi, surekli kullanim hakki. Uzun vadede daha ekonomik.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aylik abonelik (SaaS)&lt;/strong&gt;: Sunucu + yazilim + destek paket halinde aylik odeme. Daha az baslangic maliyeti.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Buyuk yayinlar genelde tek seferlik lisansi tercih eder cunku ozellestirme ve kontrol istemektedir. Kucuk/orta yayinlar baslangic maliyeti dusuk oldugu icin SaaS modellerini secebilir.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sik sorulan sorular
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;S: Ucretsiz haber scripti var mi?&lt;/strong&gt;&lt;br&gt;
C: Acik kaynak haber scriptleri vardir ama kurumsal yayinlar lisansli paketi tercih eder cunku haber ajansi entegrasyonu, AI modulleri ve uzun vadeli destek gerekir.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S: Hangi sirket en iyi haber yazilimini sunar?&lt;/strong&gt;&lt;br&gt;
C: Yayinin buyuklugune ve ozelliklerine gore degisir. Onemli olan tedarikcinin ozellestirme yapabilmesi, ajans entegrasyonuna sahip olmasi ve uzun vadeli destek vermesidir. Alesta WEB 2005ten beri bu pazarda hizmet veren bagimsiz tedarikcilerden biridir.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S: Mobil uygulama haber yaziliminin parcasi mi?&lt;/strong&gt;&lt;br&gt;
C: Modern haber yazilimlari iOS/Android native uygulama veya en az PWA olarak gelir.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S: Haber sitesi acmak icin SEO ne kadar onemli?&lt;/strong&gt;&lt;br&gt;
C: Hayati. Yayinin %60+ trafigi organik aramadan gelir. NewsArticle Schema, sitemap-news, AMP ve Google News uyumu olmazsa olmaz.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sonuc
&lt;/h2&gt;

&lt;p&gt;Turkcede "haber yazilimi", "haber scripti" ve "haber sistemi" terimleri ayni urunu uc ayri arama niyetiyle tanimlar. Yayincinin niyeti farkli olsa da tedarikci genelde ayni paketi sunar. Tedarikci secerken paketin sundugu modullerin (haber ajansi entegrasyonu, AI ile icerik, mobil uygulama, SEO, paywall) ihtiyaca uygunluguna bakmak gerekir.&lt;/p&gt;

&lt;p&gt;Detayli karsilastirma ve canli demo icin: &lt;a href="https://alestaweb.com/haber-scripti-yazilimi" rel="noopener noreferrer"&gt;Alesta WEB Haber Scripti&lt;/a&gt; veya &lt;a href="https://alestaweb.com/haber-yazilimi" rel="noopener noreferrer"&gt;Haber Yazilimi Pillar Sayfa&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Kaynak&lt;/strong&gt;: &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;Alesta WEB&lt;/a&gt; - 2005ten beri haber yazilimi, e-ticaret yazilimi ve kurumsal web cozumleri.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>cms</category>
      <category>seo</category>
    </item>
    <item>
      <title>Turkish E-commerce: Why Local POS Integration Beats Stripe (Most of the Time)</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sun, 24 May 2026 11:33:36 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/turkish-e-commerce-why-local-pos-integration-beats-stripe-most-of-the-time-e60</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/turkish-e-commerce-why-local-pos-integration-beats-stripe-most-of-the-time-e60</guid>
      <description>&lt;h1&gt;
  
  
  Turkish E-commerce: Why Local POS Integration Beats Stripe (Most of the Time)
&lt;/h1&gt;

&lt;p&gt;If you're an English-speaking developer building e-commerce in any market, your default payment integration is Stripe. It's a great default. It's documented, it's fast to integrate, it has SDKs for every language, and the API surface is among the cleanest in the industry.&lt;/p&gt;

&lt;p&gt;It's also the wrong default if your customers live in Turkey.&lt;/p&gt;

&lt;p&gt;This is a write-up of what we learned running 200+ production e-commerce sites in Turkey over the last 18 months: why Stripe alone doesn't cut it, what the local payment landscape actually looks like, the unified interface pattern we use to manage 15+ bank gateways from a single PHP codebase, and the cost numbers that justify all the extra engineering work.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Stripe Assumption (And Why It Breaks Here)
&lt;/h2&gt;

&lt;p&gt;Stripe operates in Turkey. You can technically take TRY payments through Stripe. So why isn't that the end of the story?&lt;/p&gt;

&lt;p&gt;Three reasons, in order of weight:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 1: Transaction fees compound.&lt;/strong&gt; Stripe charges around 1.4% + ₺1.40 per successful card transaction in TRY, with currency conversion and cross-border markups stacking on top in some flows. A native bank virtual POS gateway typically charges 0% transaction fee — the bank takes its cut from the merchant agreement at the bank level, not per-transaction. For a store doing ₺2M/year in volume, that's roughly ₺28,000/year that doesn't have to leave the merchant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 2: Installments (&lt;code&gt;taksit&lt;/code&gt;) are a feature, not a payment method.&lt;/strong&gt; Turkish consumers expect to see "3 ay taksit ile ₺X" alongside every product price. Installment plans are negotiated between the merchant and the issuing bank — each bank has its own installment rules, its own commission tiers, and its own "premium" cards that get extended installments. Stripe has no equivalent surface for this. You can simulate installments with a recurring subscription, but that's not what customers see at checkout, and conversion drops accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 3: TRY-native ledger.&lt;/strong&gt; Stripe settles internationally; even when collecting in TRY, the reconciliation layer is built around a multi-currency model that assumes you'll eventually want to convert. Most Turkish merchants want a Turkish lira ledger that matches their &lt;code&gt;e-fatura&lt;/code&gt; (e-invoice) records line-for-line, with VAT broken out the way GİB (Turkish tax authority) expects it. Native bank POS does this natively.&lt;/p&gt;

&lt;p&gt;The combined effect: Stripe works, but it bleeds money on transaction fees, kills your installment funnel, and adds a reconciliation step that your accountant doesn't want.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Local Payment Landscape
&lt;/h2&gt;

&lt;p&gt;Here's the actual list of payment surfaces a serious Turkish e-commerce store needs to support, at minimum:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier-1 bank virtual POS (direct integration with the issuing bank):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Garanti BBVA&lt;/li&gt;
&lt;li&gt;İş Bankası&lt;/li&gt;
&lt;li&gt;Akbank&lt;/li&gt;
&lt;li&gt;Ziraat Bankası&lt;/li&gt;
&lt;li&gt;Halkbank&lt;/li&gt;
&lt;li&gt;VakıfBank&lt;/li&gt;
&lt;li&gt;Yapı Kredi&lt;/li&gt;
&lt;li&gt;TEB&lt;/li&gt;
&lt;li&gt;DenizBank&lt;/li&gt;
&lt;li&gt;QNB Finansbank&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tier-2 payment aggregators (one integration, many banks underneath):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iyzico (Visa-owned, biggest player)&lt;/li&gt;
&lt;li&gt;PayTR&lt;/li&gt;
&lt;li&gt;Param&lt;/li&gt;
&lt;li&gt;Moka&lt;/li&gt;
&lt;li&gt;Paycell (Turkcell)&lt;/li&gt;
&lt;li&gt;Sipay&lt;/li&gt;
&lt;li&gt;Hepsipay (Hepsiburada-owned)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tier-3 alternative methods:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Papara (Turkish digital wallet)&lt;/li&gt;
&lt;li&gt;BKM Express (interbank wallet)&lt;/li&gt;
&lt;li&gt;Apple Pay / Google Pay (over local processors)&lt;/li&gt;
&lt;li&gt;Cash on delivery (still ~15% of orders in some categories)&lt;/li&gt;
&lt;li&gt;Bank transfer with auto-matching (&lt;code&gt;havale eşleştirme&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's 15+ direct gateways and at least 5 alternative payment surfaces. Realistically, a mature Turkish store integrates 5-8 of these — but the &lt;em&gt;engineering&lt;/em&gt; problem is that any one of them might be the cheapest path on a given transaction, depending on the buyer's card BIN and the merchant's bank agreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Interface That Unifies Them All
&lt;/h2&gt;

&lt;p&gt;The architectural problem looks scary the first time you face it: 15 different APIs, 15 different XML/JSON formats, 15 different 3-D Secure callback patterns, 15 different error code sets, 15 different "test card" lists.&lt;/p&gt;

&lt;p&gt;The solution we converged on (and which I'd recommend to anyone hitting this problem) is the classic adapter pattern: one interface, one set of value objects, one error taxonomy. Each gateway gets an adapter class.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getCode&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getDisplayName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;preparePayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PaymentRequest&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;PaymentPrepared&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleThreeDSCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ThreeDSResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;captureAuthorized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$txRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Money&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;CaptureResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$txRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Money&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RefundResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getInstallmentOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$cardBin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Money&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;PaymentRequest&lt;/code&gt; value object normalizes the input across all gateways: card BIN, amount in TRY minor units, installment count, merchant order reference, return URLs, customer billing address. Same call signature, regardless of which bank is on the other end.&lt;/p&gt;

&lt;p&gt;Each adapter implementation translates this normalized request into whatever the bank expects — usually XML over HTTPS for tier-1 banks, JSON for aggregators, sometimes WSDL/SOAP for legacy stacks. The translation layer is the boring part. The interesting part is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. 3-D Secure Callback: Handling 15 Different Protocols
&lt;/h2&gt;

&lt;p&gt;3-D Secure is a regulatory requirement on most card transactions in Turkey since 2020. The flow looks the same from the customer side — you redirect to the bank, the customer enters an SMS code, they redirect back — but the &lt;em&gt;integration&lt;/em&gt; side varies wildly.&lt;/p&gt;

&lt;p&gt;Concrete differences across providers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Callback method:&lt;/strong&gt; POST vs GET vs both. iyzico does POST. Some legacy banks do GET. Some do POST but expect you to verify a hash on the &lt;em&gt;next&lt;/em&gt; page load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HMAC verification:&lt;/strong&gt; SHA1, SHA256, SHA512, sometimes a custom hash with secret prefix. Order of fields in the hash payload matters and isn't always documented.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status field naming:&lt;/strong&gt; &lt;code&gt;mdStatus&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt;, &lt;code&gt;tdStatus&lt;/code&gt;, &lt;code&gt;auth_result&lt;/code&gt; — different vocabulary, sometimes different value sets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status semantics:&lt;/strong&gt; "1" can mean "3-D Secure success, proceed to auth" on one gateway and "fully authorized, capture done" on another. Confusing the two will silently capture funds without finishing 3-D, which is a compliance violation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern that saved us was a per-gateway &lt;code&gt;ThreeDSValidator&lt;/code&gt; class that returns a normalized &lt;code&gt;ThreeDSResult&lt;/code&gt; enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;ThreeDSOutcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;AUTHORIZED&lt;/span&gt;            &lt;span class="c1"&gt;// captured, money on its way&lt;/span&gt;
  &lt;span class="n"&gt;AUTHENTICATED_ONLY&lt;/span&gt;    &lt;span class="c1"&gt;// 3-D passed, auth still pending&lt;/span&gt;
  &lt;span class="n"&gt;CHALLENGE_REQUIRED&lt;/span&gt;    &lt;span class="c1"&gt;// friction beyond 3-D (rare)&lt;/span&gt;
  &lt;span class="n"&gt;REJECTED&lt;/span&gt;              &lt;span class="c1"&gt;// explicit fail&lt;/span&gt;
  &lt;span class="n"&gt;TIMEOUT&lt;/span&gt;               &lt;span class="c1"&gt;// no response, treat as fail&lt;/span&gt;
  &lt;span class="n"&gt;TAMPER_DETECTED&lt;/span&gt;       &lt;span class="c1"&gt;// HMAC failed, log + alert&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order routing layer doesn't care which bank just called us back. It cares only about which of those six outcomes happened. That decoupling is the single most valuable thing in the whole stack — it means adding a new gateway is a 200-line adapter and a test fixture, not a redesign.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Real Cost Comparison: Stripe vs Native (3-Year Data)
&lt;/h2&gt;

&lt;p&gt;Numbers from a representative mid-sized merchant in our portfolio. Online fashion, ~₺3.2M annual volume, average basket ₺240, ~13,300 orders/year. Card mix: 78% Turkish-issued credit, 18% Turkish debit, 4% international.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario A — Stripe-only:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transaction fee: 1.4% + ₺1.40 on Turkish cards (after volume discount). Roughly ₺44,800 + ₺18,620 = ₺63,420/year in transaction fees.&lt;/li&gt;
&lt;li&gt;Foreign cards (4%, ~₺128k volume): 2.9% + ₺2 = ₺3,712 + ₺1,064 = ₺4,776&lt;/li&gt;
&lt;li&gt;Installment funnel: not available natively. Either skipped (conversion drops ~12-18% on baskets over ₺1,000) or hacked via off-platform subscription (compliance grey area).&lt;/li&gt;
&lt;li&gt;Reconciliation: TRY/USD ledger split, manual e-invoice mapping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Scenario B — iyzico + 4 direct bank gateways (Garanti, İş, Akbank, Ziraat):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Aggregator fee on iyzico-routed transactions (~30% of volume): 2.4% blended ≈ ₺23,040/year&lt;/li&gt;
&lt;li&gt;Direct bank fees on routed transactions (~70% of volume): 0% transaction, bank takes monthly fixed fee ≈ ₺18,000/year total across 4 banks&lt;/li&gt;
&lt;li&gt;Installment funnel: full native, all banks expose their installment offers&lt;/li&gt;
&lt;li&gt;Reconciliation: TRY-native, line-for-line with e-arşiv records&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total: ₺41,040/year&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Difference: ₺27,156/year&lt;/strong&gt; in direct fees, plus the conversion lift from installments — which on this volume mix is worth roughly another ₺180-220k in additional revenue.&lt;/p&gt;

&lt;p&gt;The catch is the engineering investment. Building and maintaining the gateway layer is real work — call it 60-80 engineering days for the first build, plus 10-15 days/year of maintenance as banks shuffle their APIs. For merchants under ~₺1M annual volume, Stripe is genuinely the right call: the savings don't outrun the engineering cost. Above that line, native wins, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. When Stripe Still Wins
&lt;/h2&gt;

&lt;p&gt;Honest take: there are still cases where Stripe is the right answer even in Turkey.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You sell mostly to non-Turkish customers in TRY&lt;/strong&gt; (export-heavy stores, language schools selling to expats). Stripe's multi-currency surface is genuinely useful, and you don't need installments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're pre-product-market-fit.&lt;/strong&gt; Don't sink 60 days into a payment layer when you're not sure anyone wants to buy. Ship with iyzico (one integration, decent coverage) and migrate later if volume justifies it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You operate B2B with invoice-based settlement.&lt;/strong&gt; Card transactions are a tiny fraction of revenue; the payment gateway is not your bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're on a serverless/SaaS PHP-incompatible stack&lt;/strong&gt; where the maintenance overhead of bank adapters falls on a team that doesn't have Turkish-language docs comprehension. Hiring local engineers for that is more expensive than the fees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everyone else — every Turkish-market store with national reach and &amp;gt;₺1M volume — local POS is not optional. It's table stakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Conclusion: Local Fintech Isn't Optional
&lt;/h2&gt;

&lt;p&gt;The mistake we made early on was treating Turkish payments as "Stripe with a TRY currency code." That mental model produces a working store and a slowly bleeding P&amp;amp;L. The right mental model is: this is a market with its own payment culture, its own regulatory frame, and its own gateway ecosystem, and the engineering effort to plug into it is one of the highest-ROI investments a Turkish e-commerce engineering team can make.&lt;/p&gt;

&lt;p&gt;If you're starting fresh, build the adapter layer from day one even if you only ship with one gateway initially. The interface costs almost nothing to write up front. Retrofitting it later — when your order layer has direct calls to &lt;code&gt;iyzico_client-&amp;gt;charge()&lt;/code&gt; scattered across 40 files — is the part that's painful.&lt;/p&gt;

&lt;p&gt;For more on the broader Turkish e-commerce engineering stack (CMS, bot integrations, AI cost optimization), see my earlier post on &lt;a href="https://dev.to/mahmut_gndzalp_c736ac4b/building-a-multi-llm-news-cms-with-php-82-lessons-from-200-production-sites-48dd"&gt;building a multi-LLM news CMS&lt;/a&gt; and the &lt;a href="https://dev.to/mahmut_gndzalp_c736ac4b/why-we-switched-from-react-to-htmx-in-production-a-200-site-case-study-5hgk"&gt;React-to-HTMX migration writeup&lt;/a&gt;. Same production stack, different surface.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Working on Turkish e-commerce or news CMS infrastructure? I run &lt;a href="https://alestaweb.com" rel="noopener noreferrer"&gt;Alesta WEB&lt;/a&gt;, an Şanlıurfa-based software shop building this kind of platform for the Turkish market since 2005. Happy to compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>php</category>
      <category>payments</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Why We Switched from React to HTMX in Production: A 200-Site Case Study</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sat, 23 May 2026 09:19:58 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/why-we-switched-from-react-to-htmx-in-production-a-200-site-case-study-5hgk</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/why-we-switched-from-react-to-htmx-in-production-a-200-site-case-study-5hgk</guid>
      <description>&lt;p&gt;We ran a React SPA admin panel for almost three years. It worked. Customers logged in, edited content, published articles. Bundle size kept creeping up. Build times kept creeping up. A new dev needed two weeks to be productive. We started skipping minor features because "the diff is too risky."&lt;/p&gt;

&lt;p&gt;In Q3 2025 we migrated that panel to HTMX over six months, route by route. This post is the honest version of how it went — what worked, what we didn't see coming, and the numbers from running both stacks side by side across more than 200 production deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The React tax in 2026
&lt;/h2&gt;

&lt;p&gt;Let me get one thing out of the way: React isn't broken. It's a fine tool for the workloads it was designed for. Our admin panel was not one of those workloads. Most of our screens are forms, lists, and modal dialogs. The fanciest interaction is drag-to-reorder. The actual user count per tenant is small — usually 1 to 5 editors per site.&lt;/p&gt;

&lt;p&gt;For that, here's what React was costing us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bundle size:&lt;/strong&gt; ~800 KB gzipped after route-splitting, three vendor bundles, three lazy chunks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First admin login LCP:&lt;/strong&gt; 3.0s to 3.5s depending on region (we serve from a single Istanbul edge)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build time:&lt;/strong&gt; 90 seconds for production, 8 seconds for dev rebuild&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding:&lt;/strong&gt; new hires needed 10–14 days before they could ship a self-contained feature&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling churn:&lt;/strong&gt; in three years we went through three state management libraries and two router majors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are dealbreakers in isolation. Stacked together, they made every small change expensive. We were paying SPA prices for a CRUD app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HTMX caught our attention
&lt;/h2&gt;

&lt;p&gt;The pitch is one line: HTMX lets any element issue an AJAX request and swap the response into the DOM. There's no client-side router, no virtual DOM, no build step required. You render HTML on the server (we use Smarty 5), the browser swaps fragments, the network does the heavy lifting.&lt;/p&gt;

&lt;p&gt;What sold us wasn't the elegance of the demo. It was a 40-minute spike where one engineer rebuilt our "edit article" screen — form, validation, autosave, image upload — in 180 lines of HTML + a thin PHP controller. The React version was 1,400 lines across 9 files.&lt;/p&gt;

&lt;p&gt;The interesting part: the HTMX version felt faster, and was. No JS bundle to parse, no hydration step. The TTI was essentially the same as the LCP because there was nothing to hydrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration strategy: parallel routes, no big bang
&lt;/h2&gt;

&lt;p&gt;We've been burned by big-bang rewrites before. This time we did parallel routes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Both stacks live in the same admin panel. Old routes (&lt;code&gt;/admin/old/*&lt;/code&gt;) keep serving React. New routes (&lt;code&gt;/admin/*&lt;/code&gt;) serve server-rendered HTML with HTMX.&lt;/li&gt;
&lt;li&gt;A shared session cookie means a user can be in the middle of editing in React, click a sidebar link, and land in the HTMX side without re-authenticating.&lt;/li&gt;
&lt;li&gt;We migrated one feature per sprint, easiest first (read-only lists), hardest last (the article editor with WYSIWYG).&lt;/li&gt;
&lt;li&gt;After each feature shipped, we deleted the React route and the JS that supported it. Bundle size dropped in steps — that visibility kept morale up.&lt;/li&gt;
&lt;li&gt;The cutover wasn't a date on the calendar. It was the moment the React bundle hit zero. That happened in week 22.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The "no big bang" rule matters. If we'd tried to ship the whole panel in one PR, we wouldn't have shipped at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three patterns we use everywhere
&lt;/h2&gt;

&lt;p&gt;Most of the panel is built from three patterns. If you understand these, you understand 80% of an HTMX codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Form submit with inline validation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;hx-post=&lt;/span&gt;&lt;span class="s"&gt;"/admin/articles"&lt;/span&gt;
      &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#form-result"&lt;/span&gt;
      &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"outerHTML"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Save&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form-result"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server returns either a success fragment or the same form re-rendered with inline error messages. No client-side validation library. No form library. The server is the single source of truth.&lt;/p&gt;

&lt;p&gt;The win: we deleted ~5,000 lines of duplicated client-side validation that was always one schema change away from drifting from the server.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Infinite scroll for long lists
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"article-list"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;article&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;article&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;hx-get=&lt;/span&gt;&lt;span class="s"&gt;"/admin/articles?page=2"&lt;/span&gt;
       &lt;span class="na"&gt;hx-trigger=&lt;/span&gt;&lt;span class="s"&gt;"revealed"&lt;/span&gt;
       &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"outerHTML"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Loading...
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sentinel div triggers when scrolled into view, fetches the next page, and replaces itself with the next batch (plus a new sentinel). One pattern, every long list. No virtual scrolling library, no IntersectionObserver setup code in userland.&lt;/p&gt;

&lt;p&gt;For lists over ~10,000 items we still reach for virtual scrolling, but those are rare in an admin context.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Modal dialogs with hx-target
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;hx-get=&lt;/span&gt;&lt;span class="s"&gt;"/admin/articles/42/edit"&lt;/span&gt;
        &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#modal"&lt;/span&gt;
        &lt;span class="na"&gt;hx-trigger=&lt;/span&gt;&lt;span class="s"&gt;"click"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Edit
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"modal"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server returns the modal markup including a &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element with &lt;code&gt;open&lt;/code&gt;. To close, the modal posts back and returns an empty fragment that replaces itself. State of the dialog lives on the server.&lt;/p&gt;

&lt;p&gt;This one took the longest to internalize. The instinct from React land is to manage modal state in a store. With HTMX, the modal is just a fragment of HTML that the server hands you when you ask for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, after six months
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;React (before)&lt;/th&gt;
&lt;th&gt;HTMX (after)&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Admin bundle (gzipped)&lt;/td&gt;
&lt;td&gt;800 KB&lt;/td&gt;
&lt;td&gt;~50 KB&lt;/td&gt;
&lt;td&gt;–94%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP (Istanbul, p75)&lt;/td&gt;
&lt;td&gt;3.2s&lt;/td&gt;
&lt;td&gt;1.1s&lt;/td&gt;
&lt;td&gt;–66%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTI (Istanbul, p75)&lt;/td&gt;
&lt;td&gt;4.1s&lt;/td&gt;
&lt;td&gt;1.2s&lt;/td&gt;
&lt;td&gt;–71%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production build time&lt;/td&gt;
&lt;td&gt;90s&lt;/td&gt;
&lt;td&gt;6s&lt;/td&gt;
&lt;td&gt;–93%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev rebuild&lt;/td&gt;
&lt;td&gt;8s&lt;/td&gt;
&lt;td&gt;&amp;lt;1s&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend response p95&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;td&gt;220ms&lt;/td&gt;
&lt;td&gt;+22%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total admin LOC&lt;/td&gt;
&lt;td&gt;~42,000&lt;/td&gt;
&lt;td&gt;~28,000&lt;/td&gt;
&lt;td&gt;–33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev onboarding (days)&lt;/td&gt;
&lt;td&gt;10–14&lt;/td&gt;
&lt;td&gt;3–5&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;p&gt;The 50 KB on the HTMX side is HTMX itself plus a tiny amount of our own glue code (~600 lines). No build pipeline required, though we keep a Vite step for CSS bundling.&lt;/p&gt;

&lt;p&gt;Backend response time &lt;strong&gt;went up&lt;/strong&gt;. That's not free — server rendering moved work from the client to the server. We mitigated with aggressive caching of partials (Smarty + Redis), but the trade is real: you pay in server CPU what you save in client work.&lt;/p&gt;

&lt;p&gt;The LOC drop surprised us. We expected maybe 10–15%. The 33% came mostly from deleting client-side mirrors of server state — form models, validation, optimistic update logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where HTMX falls short — real talk
&lt;/h2&gt;

&lt;p&gt;This is the section I wish more "we switched to X" posts included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline support is gone.&lt;/strong&gt; If you need a panel that works on a flaky connection, HTMX is the wrong tool. Every interaction is a network round-trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Complex client interactions get awkward.&lt;/strong&gt; We have one screen — a drag-and-drop tree editor for category hierarchy — that's still React. HTMX can do drag-and-drop with &lt;code&gt;sortable.js&lt;/code&gt;, but the round-trip-per-drop model breaks down for fine-grained interactions. Use the right tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic UI requires effort.&lt;/strong&gt; In React we'd just update local state and roll back on error. With HTMX you can simulate this with &lt;code&gt;hx-swap-oob&lt;/code&gt; and some discipline, but it's more code, not less.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend team needs to care about HTML.&lt;/strong&gt; This sounds obvious, but if your backend devs have been shipping pure JSON for five years, the switch to "you also own the fragment markup" is a real culture change. Some loved it. Some resisted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser DevTools are less helpful.&lt;/strong&gt; No component tree, no React DevTools. You're back to inspecting the DOM and reading network requests. After a week we stopped missing the component tree, but the first week was rough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing changed.&lt;/strong&gt; We dropped React Testing Library and most Jest tests. We added more PHP integration tests that fetch endpoints and assert on the returned HTML. Total test count went down ~40% but coverage actually improved — we were testing implementation details before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;For a CRUD admin panel with a small concurrent user count, serving server-rendered HTML over the wire and letting the browser do what the browser is already good at — yes, very much.&lt;/p&gt;

&lt;p&gt;The cost shifted: we moved complexity from the client to the server, which means we now care more about backend cache hit rates and partial rendering performance than about React render performance. That's a tractable problem for the team we have.&lt;/p&gt;

&lt;p&gt;We're not evangelists. The frontend team kept React for our customer-facing storefront editor, where rich interaction and offline-first matter. The right architecture is the one that fits the workload.&lt;/p&gt;

&lt;p&gt;If you're sitting on a React-built admin panel that feels heavier than the problem it solves, do a one-week spike on the smallest screen. Measure. If the numbers above look like yours, you might save more by deleting code than by writing it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of an engineering blog series from Alesta WEB, where we build news CMS and e-commerce platforms used by 200+ production sites in Turkey. Other posts cover &lt;a href="https://dev.to/mahmut_gndzalp_c736ac4b/building-a-multi-llm-news-cms-with-php-82-lessons-from-200-production-sites-48dd"&gt;our multi-LLM CMS architecture&lt;/a&gt; and more.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>htmx</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building a Multi-LLM News CMS with PHP 8.2: Lessons from 200+ Production Sites</title>
      <dc:creator>Mahmut Gündüzalp</dc:creator>
      <pubDate>Sat, 16 May 2026 19:05:17 +0000</pubDate>
      <link>https://dev.to/mahmut_gndzalp_c736ac4b/building-a-multi-llm-news-cms-with-php-82-lessons-from-200-production-sites-48dd</link>
      <guid>https://dev.to/mahmut_gndzalp_c736ac4b/building-a-multi-llm-news-cms-with-php-82-lessons-from-200-production-sites-48dd</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Over the past 21 years, our team has helped build and maintain a news content management ecosystem that now powers 200+ active news portals across Turkey. In the last 18 months, we've integrated six different LLM providers (OpenAI, Anthropic, Google Gemini, DeepSeek, Groq, and Mistral) into news production workflows.&lt;/p&gt;

&lt;p&gt;This article shares the &lt;strong&gt;architectural decisions and lessons&lt;/strong&gt; we've learned — not implementation specifics, but the why-and-when of multi-LLM systems for news publishing. The patterns that let us reduce AI inference costs by &lt;strong&gt;~95%&lt;/strong&gt; while keeping quality high.&lt;/p&gt;

&lt;p&gt;No buzzwords. Just decisions that hold up in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Multi-LLM Instead of "Just Use GPT-4"?
&lt;/h2&gt;

&lt;p&gt;Three reasons we don't rely on a single AI provider:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Cost optimization.&lt;/strong&gt; GPT-4o costs $2.50/M input tokens. Gemini Flash costs $0.075/M — 33x cheaper. A simple summary task doesn't need GPT-4o's reasoning. Routing tasks to the right model means massive savings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Vendor independence.&lt;/strong&gt; When OpenAI had outages in 2024-2025, sites that relied solely on GPT broke. Multi-provider setups fell back to Claude or Gemini seamlessly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Specialized strengths.&lt;/strong&gt; Claude is better at long-context reasoning. Gemini is better at structured output. Groq is fastest for real-time chat. Mistral handles multilingual content well. Each provider has a sweet spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cascade Routing Strategy
&lt;/h2&gt;

&lt;p&gt;The core idea: try the fastest and cheapest model first; fall back to more capable (and expensive) models only when needed.&lt;/p&gt;

&lt;p&gt;For a news CMS, this means categorizing tasks by complexity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simple summaries&lt;/strong&gt; → fast cheap models (Groq Llama, Gemini Flash)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headline suggestions&lt;/strong&gt; → mid-tier models (GPT-4o-mini, Claude Haiku)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO meta generation&lt;/strong&gt; → cheap models suffice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-form content generation&lt;/strong&gt; → premium models (Claude Sonnet, GPT-4o)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fact-checking&lt;/strong&gt; → highest reliability tier (accuracy critical)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translation&lt;/strong&gt; → mid-tier multilingual models&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each task type gets a &lt;strong&gt;fallback chain&lt;/strong&gt;. If the primary model is rate-limited or unavailable, the system tries the next one — no human intervention needed.&lt;/p&gt;

&lt;p&gt;The savings come from the realization that &lt;strong&gt;most news CMS tasks don't need the smartest model&lt;/strong&gt;. A two-line summary of a news article doesn't require frontier reasoning. Reserving premium models for the genuinely hard tasks (complex analysis, fact-checking) is where multi-LLM pays off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Abstraction Matters
&lt;/h2&gt;

&lt;p&gt;Each AI provider has different SDKs, request formats, authentication, error handling, and pricing models. Hiding all of this behind a &lt;strong&gt;common interface&lt;/strong&gt; is what makes multi-LLM practical.&lt;/p&gt;

&lt;p&gt;The principle is simple: any provider should be swappable without changing the calling code. A workflow that "summarizes an article" shouldn't care if it's OpenAI, Anthropic, or Google under the hood. Today it might be Gemini Flash; next month it might be a new provider that didn't exist when the code was written.&lt;/p&gt;

&lt;p&gt;This abstraction also makes A/B testing painless. Want to know if Claude Sonnet produces better summaries than GPT-4o for Turkish news? Route 50% of traffic to each, measure quality and cost, decide. Without abstraction, this experiment would require parallel codebases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Optimization: 95% Reduction in Practice
&lt;/h2&gt;

&lt;p&gt;The cost reduction comes from three compounding layers:&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Caching (~60% of savings)
&lt;/h3&gt;

&lt;p&gt;Many news CMS tasks are &lt;strong&gt;deterministic&lt;/strong&gt;: "summarize this article" with the same article produces the same answer. Cache once, reuse forever (until the source content changes).&lt;/p&gt;

&lt;p&gt;Real-world cache hit rate in production: ~70% for common tasks like summaries, SEO meta tags, and headline suggestions.&lt;/p&gt;

&lt;p&gt;The trick is knowing &lt;strong&gt;what to cache and what not to&lt;/strong&gt;. Personalized content, real-time chat, and time-sensitive analysis shouldn't be cached. But the bread-and-butter of news editing (summarize, tag, rewrite headline) is highly cacheable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Batch APIs (~25% additional savings)
&lt;/h3&gt;

&lt;p&gt;OpenAI's Batch API offers 50% discount with a 24-hour SLA. Anthropic offers the same. Many news tasks don't need to happen in real-time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overnight SEO meta generation for the day's articles&lt;/li&gt;
&lt;li&gt;Bulk product description generation for e-commerce catalogs&lt;/li&gt;
&lt;li&gt;Archived content tagging and categorization&lt;/li&gt;
&lt;li&gt;Translation backlog processing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Workers collect these into batches and submit them periodically. The savings compound across thousands of operations per day.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Cascade Routing (~10% additional savings)
&lt;/h3&gt;

&lt;p&gt;By the time tasks reach a premium model, they've already been filtered through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cache (free)&lt;/li&gt;
&lt;li&gt;Cheap model attempt (Groq, Gemini Flash)&lt;/li&gt;
&lt;li&gt;Mid-tier model attempt (GPT-4o-mini, Claude Haiku)&lt;/li&gt;
&lt;li&gt;Premium model (only when truly needed)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Quality gates between layers reject inadequate outputs from cheap models, but most outputs pass the gate. Premium model usage drops to &amp;lt;10% of total inference calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turkish News Agency Landscape
&lt;/h2&gt;

&lt;p&gt;This is where geographic context matters. Turkey's news ecosystem revolves around 8 major wire services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anadolu Ajansı (AA)&lt;/strong&gt; — state news agency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demirören Haber Ajansı (DHA)&lt;/strong&gt; — major commercial wire&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;İhlas Haber Ajansı (İHA)&lt;/strong&gt; — conservative-aligned wire&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ANKA, THA, HİBYA, İGFA, BHA&lt;/strong&gt; — regional and specialized agencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each has its own content format, distribution protocol, and category taxonomy. Generic global CMS platforms (WordPress, Drupal) don't handle this — there's no "Turkish news agency plugin" that connects all eight.&lt;/p&gt;

&lt;p&gt;The implementation pattern here is &lt;strong&gt;adapter-style integration&lt;/strong&gt;: each agency gets its own integration module that conforms to a common interface, so the downstream workflow doesn't care which agency the content came from. Adding a 9th or 10th agency becomes a few days of work, not a months-long rewrite.&lt;/p&gt;

&lt;p&gt;A scheduled job runs every few minutes, fetches new articles from all enabled agencies in parallel, normalizes image formats (WebP conversion for web performance), generates responsive thumbnails, deduplicates against existing content, and stores everything in a moderation queue. Editors then review, approve, edit, or reject — never publishing raw wire content blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Visibility: SEO's Next Frontier
&lt;/h2&gt;

&lt;p&gt;Traditional SEO targets Google. &lt;strong&gt;AI visibility&lt;/strong&gt; targets ChatGPT, Claude, Gemini, Perplexity, and their successors. The standards are emerging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/strong&gt; — a markdown file similar to &lt;code&gt;robots.txt&lt;/code&gt; but content-focused. It tells LLM crawlers what your site is about, key sections, and how to navigate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ai-sitemap.xml&lt;/code&gt;&lt;/strong&gt; — like &lt;code&gt;sitemap.xml&lt;/code&gt; but with article summaries and structured metadata that LLMs can ingest efficiently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema.org JSON-LD&lt;/strong&gt; — &lt;code&gt;NewsArticle&lt;/code&gt;, &lt;code&gt;NewsMediaOrganization&lt;/code&gt;, &lt;code&gt;BreadcrumbList&lt;/code&gt;, &lt;code&gt;FAQPage&lt;/code&gt; markups give crawlers structured access to content semantics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bot allow/disallow rules&lt;/strong&gt; — explicitly permitting GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, CCBot, Bytespider, AppleBot, and Google-Extended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bet is that &lt;strong&gt;LLM-based search and answer engines will eventually rival Google for content discovery&lt;/strong&gt;. Sites optimized only for traditional SEO will lose visibility in this new layer. Adding AI visibility to the standard SEO checklist is cheap insurance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Numbers
&lt;/h2&gt;

&lt;p&gt;After 18 months of running multi-LLM stacks across news production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI inference cost reduction:&lt;/strong&gt; approximately 95% vs naive GPT-4o-only approach&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache hit rate:&lt;/strong&gt; approximately 70% on common tasks (summaries, headlines, SEO meta)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider availability:&lt;/strong&gt; 99.97% (vs single-provider ~99.5%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing throughput:&lt;/strong&gt; sub-second cached responses, 1-3 seconds for fresh inference&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agency content ingestion:&lt;/strong&gt; 8 agencies polled regularly, thousands of articles processed daily&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These numbers come from real production environments, not benchmarks. Your mileage will vary depending on traffic patterns, cache TTL strategy, and quality requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;After 21 years building CMS software and 18 months optimizing for AI:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Don't lock into one provider.&lt;/strong&gt; It's tempting to "just use OpenAI." Don't. The day they have an outage or change pricing, you'll wish you had alternatives ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cache aggressively, but thoughtfully.&lt;/strong&gt; Most AI tasks repeat with deterministic outputs. Cache them. But know which tasks must always be fresh (personalized, real-time, time-sensitive).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Route by task complexity, not by hype.&lt;/strong&gt; Most tasks don't need GPT-4o or Claude Opus. A cheap model gets 90% of the work done at 5% of the cost. Save premium models for genuinely hard tasks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Local regulations are first-class concerns.&lt;/strong&gt; In Turkey: KVKK (data protection), İYS (marketing consent registry), BİK (Press Advertising Authority) compliance. In EU: GDPR, AI Act. Don't bolt these on later — design for them from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Quality gates matter.&lt;/strong&gt; A cheap model giving wrong answers is more expensive than an expensive model giving right ones (especially when wrong outputs damage brand trust). Add validation between cascade layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Stable beats shiny.&lt;/strong&gt; Modern PHP isn't trendy. Smarty isn't trendy. MySQL isn't trendy. They all run reliably for years. The newest framework will be deprecated in three. Pick stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;The "best" architecture for a news CMS isn't the most novel. It's the one that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Works reliably for years&lt;/li&gt;
&lt;li&gt;Costs less than what you charge clients&lt;/li&gt;
&lt;li&gt;Handles local quirks (regulatory, linguistic, cultural)&lt;/li&gt;
&lt;li&gt;Survives provider deprecations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Multi-LLM with cascade routing and aggressive caching fits that bill in 2026. It will probably fit it in 2030 too — the providers will change, but the abstraction principle won't.&lt;/p&gt;

&lt;p&gt;If you're building or evaluating a multi-provider AI architecture, focus on &lt;strong&gt;decision points&lt;/strong&gt; rather than specific implementations. The provider you start with may not be the one you finish with. The pattern that works today should still work when the entire AI landscape has rotated through three or four hype cycles.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about practical software architecture, multi-LLM systems, and lessons from running CMS at scale. Feel free to drop questions in the comments — I read all of them, even when I don't reply quickly.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
