<?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: Canercan Demir</title>
    <description>The latest articles on DEV Community by Canercan Demir (@cn8001).</description>
    <link>https://dev.to/cn8001</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3831287%2F89fb4580-8493-486e-8600-27f25d1c5eeb.png</url>
      <title>DEV Community: Canercan Demir</title>
      <link>https://dev.to/cn8001</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cn8001"/>
    <language>en</language>
    <item>
      <title>Every Dev Has the New-Project Itch. Rapid Prototyping Makes It Creative.</title>
      <dc:creator>Canercan Demir</dc:creator>
      <pubDate>Thu, 09 Apr 2026 15:07:24 +0000</pubDate>
      <link>https://dev.to/cn8001/every-dev-has-the-new-project-itch-rapid-prototyping-makes-it-creative-3ocf</link>
      <guid>https://dev.to/cn8001/every-dev-has-the-new-project-itch-rapid-prototyping-makes-it-creative-3ocf</guid>
      <description>&lt;p&gt;Inside every developer lives a second self — the one who wants a new project the moment they get bored with the current one.&lt;/p&gt;

&lt;p&gt;Most advice says you should fight that self. Discipline. Focus. Finish what you start.&lt;/p&gt;

&lt;p&gt;I think that's wrong. 99% of the ideas that second self hands you really are going nowhere. But the 1% is worth every other one combined — and the only way to find out which is which is to let the second self speak, &lt;strong&gt;fast&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tutorial hell&lt;/strong&gt; is when you follow step-by-step guides without actually understanding them. You open a "Build X with Y" article, you type the code the author tells you to type, you rename a few variables, and six hours later you have a thing that runs. It looks like you learned something. You didn't. You learned the shape of someone else's decisions — their project layout, their preferred libraries, their stylistic quirks — but you didn't make a single call of your own. The moment your own idea diverges from the tutorial by one step, you're stuck. You close the tab, tell yourself you need "more practice", and open the next tutorial. Repeat for years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rapid prototyping&lt;/strong&gt; looks similar from the outside: you start, you don't finish, you move on. The &lt;em&gt;content&lt;/em&gt; is completely different. You make every decision yourself. You don't know the "correct" way to do it, so you pick the shortest path. The result is ugly but it's yours, and every line of it represents a choice you made — which means you can extend it, break it, debug it, or throw it away on purpose.&lt;/p&gt;

&lt;p&gt;Concrete example. Say the idea is a habit tracker.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tutorial hell version:&lt;/em&gt; "I'll follow a 'Build a Habit Tracker App' tutorial." Six hours later you've typed the code the author told you to type. You have something that runs. If someone asks you why &lt;code&gt;useEffect&lt;/code&gt; has an empty dependency array, you shrug. If you want to add a feature the tutorial didn't cover, you go hunting for another tutorial. Somewhere along the way the original idea of "a habit tracker" became irrelevant — you're just solving the puzzles the author gave you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rapid prototyping version:&lt;/em&gt; "Can habits be tracked with a bash alias that appends to &lt;code&gt;~/.habits.txt&lt;/code&gt;?" Thirty minutes later, yes they can. Next morning: did you actually use it? No → the idea was the problem, not the stack. Delete it. Yes → now you have a signal that something is worth building, and you already know the minimum it has to do.&lt;/p&gt;

&lt;p&gt;The difference isn't how much time you spend. It's &lt;strong&gt;who made the decisions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Tutorial hell answers: &lt;em&gt;"Did I finish the tutorial?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Rapid prototyping answers: &lt;em&gt;"Does this idea even work?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Those are not the same question. And the side-project graveyard in most developers' GitHubs is full of answers to the wrong one&lt;/p&gt;




&lt;h2&gt;
  
  
  Rapid prototyping's real superpower
&lt;/h2&gt;

&lt;p&gt;Here's what I missed for years: the point of rapid prototyping isn't speed. It's &lt;strong&gt;feasibility&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you start a project the traditional way, you commit before you know anything. Two days of setup, one day picking a UI kit, half a day deciding between Postgres and SQLite — and you've spent a week on the question &lt;em&gt;"what stack should I use?"&lt;/em&gt; before you've touched the question that actually matters: &lt;em&gt;does this idea work at all?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Rapid prototyping inverts that order. You answer the second question first. You build the ugliest possible version of the idea — a single HTML file, a bash script, a route that returns JSON — and you look at it. Not the code. The thing the code does. And you ask: &lt;em&gt;is this useful? is it fun? does it solve the problem I thought it did?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most of the time, the answer is no. That's the part people don't want to hear. Most ideas that look brilliant in your head collapse the moment they exist in the real world. You only find out by making the cheapest possible version first.&lt;/p&gt;

&lt;p&gt;Here's the reframe that finally clicked for me. The second self — the voice inside every developer that wants a new project every time the current one slows down — isn't what needs fixing. The &lt;em&gt;cost of listening to it&lt;/em&gt; is. When that cost is two weeks of setup per impulse, you either fight the second self with willpower, or you end up in a graveyard of half-finished folders. When the cost is two hours, you can let it speak. You can try the thing. Most attempts still collapse — but you find out in an afternoon instead of a month, and the attempts that don't collapse have already proved themselves &lt;em&gt;before&lt;/em&gt; you commit to them.&lt;/p&gt;

&lt;p&gt;That's the real move. Not "follow every impulse." Not "ignore every impulse." &lt;strong&gt;Reduce the price of every impulse until it stops mattering whether the impulse was right.&lt;/strong&gt; Feasibility testing by default, built into the way you start.&lt;/p&gt;

&lt;p&gt;Which brings me to the thing I actually built.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thing I built: PointArt
&lt;/h2&gt;

&lt;p&gt;A month ago, PointArt didn't exist. My default stack was Spring Boot — that's what I reach for when I start anything serious. Then a client asked for something simple: just a website. Nothing that justified spinning up a JVM.&lt;/p&gt;

&lt;p&gt;I'd written PHP years ago and hadn't touched it in a while. Sitting with the brief, I started remembering how cheap PHP can be, shared hosting for a few dollars a month. Aggressively un-cool and aggressively cheap. But I didn't want to give up the parts of Spring that made me productive: JPA, dependency injection, attribute-based routing. Those aren't luxuries once you've built real apps with them — they're how you move fast without shooting yourself in the foot.&lt;/p&gt;

&lt;p&gt;So I built my own. &lt;a href="https://pointartframework.com" rel="noopener noreferrer"&gt;PointArt&lt;/a&gt; is a PHP microframework with zero dependencies that runs on the cheapest shared hosting you can find and carries the Spring-shaped programming model I was used to. Attribute routing. Dependency injection. An ORM with repositories.&lt;/p&gt;

&lt;p&gt;The original reason for building it was one client site. But once you have a framework you trust, everything downstream gets cheaper — and &lt;em&gt;that's&lt;/em&gt; where the ratchet came from. My cold-start cost for a new prototype dropped from a week to an afternoon, and every idea I'd been putting off became worth trying.&lt;/p&gt;

&lt;p&gt;The first thing I built on top of it was a RAG system — an AI retrieval-augmented-generation demo for a different idea I had been sitting on. &lt;strong&gt;I never published it.&lt;/strong&gt; The prototype took an afternoon and it answered the question: &lt;em&gt;does the idea work?&lt;/em&gt; (Not really.) But more importantly, it answered the question the framework actually needed: &lt;em&gt;what's annoying about writing a real app on this thing?&lt;/em&gt; The first round of framework fixes came directly out of that RAG prototype. Nothing about the RAG itself survived. Everything I learned about the framework did.&lt;/p&gt;

&lt;p&gt;The second thing had a longer runway than PointArt itself. I took a game design course in college and I've been a quiet fan of browser-based games for years — the kind you open in a tab with no install, no launcher, no friction. Recently I'd stumbled across &lt;a href="https://phaser.io/" rel="noopener noreferrer"&gt;Phaser.js&lt;/a&gt; and realised I finally had the right tool to make one myself. The PointArt prototype was the excuse I'd been waiting for.&lt;/p&gt;

&lt;p&gt;So I built GameHub — a browser-based game platform with a dot-com era deckbuilder called DotCom Broker 98 as its first resident. It's in beta right now at &lt;a href="https://gamehub.pointartframework.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=prelaunch_2026" rel="noopener noreferrer"&gt;PointArt GameHub&lt;/a&gt;. Everything runs in the browser: no download, no install, no app store. The game itself is fully web-based and server-authoritative, so the client can stay as dumb as Phaser allows.&lt;/p&gt;

&lt;p&gt;That's three live pieces in about a month:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PointArt&lt;/strong&gt; — the framework itself, live at &lt;a href="https://pointartframework.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=prelaunch_2026" rel="noopener noreferrer"&gt;pointartframework.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The PointArt website&lt;/strong&gt; — the docs + marketing site, built on the framework itself (eating its own food from day one)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GameHub&lt;/strong&gt; — in beta, the first real load test for the framework at scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus one unpublished experiment — the RAG — whose only lasting purpose turned out to be sharpening the tool that built the next two.&lt;/p&gt;

&lt;p&gt;That's what the ratchet looks like in practice. The graveyard version of this same month would have been three half-finished folders in my &lt;code&gt;Projects/&lt;/code&gt; directory and nothing to show anyone. The ratchet version is three URLs, one platform, and a pile of concrete lessons heading into whatever I build next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fundamentals first, then speed
&lt;/h2&gt;

&lt;p&gt;Everyone quotes Kent Beck's &lt;em&gt;"make it work, make it right, make it fast"&lt;/em&gt; — and most solo devs get it exactly backwards. They try to do all three at once on a brand-new prototype. They reach for the perfect ORM before they've validated the idea. They argue about what database to use before they've written a single line of feature code. Then they stall out, call it a "focus problem", and move on to the next project.&lt;/p&gt;

&lt;p&gt;Kent Beck wasn't handing us three boxes to tick simultaneously. He was handing us three &lt;strong&gt;gates&lt;/strong&gt;. Make it work — and &lt;em&gt;only after&lt;/em&gt; the thing actually works, and &lt;em&gt;only if&lt;/em&gt; it deserves to exist, do you graduate it to "make it right". Only after "right" is meaningfully right do you earn the question of "fast". The gates are the whole point. Skipping the gates is what kills solo projects, not the order itself.&lt;/p&gt;

&lt;p&gt;Rapid prototyping, stripped of the buzzword, is just phase 1 taken seriously. Make it work. Don't worry about refactoring — you haven't earned that question yet. Don't worry about performance — you don't have users yet. Just: does this thing, in its ugliest possible form, answer the question I set out to ask? If yes, you graduate it. If no, you delete it and move on without guilt, because you never paid the "right" tax on an idea that didn't deserve it.&lt;/p&gt;

&lt;p&gt;There's a catch, though. &lt;strong&gt;Phase 1 is only cheap if you can actually write phase-1 code.&lt;/strong&gt; "The ugliest possible version" still has to be real code that runs. If you don't know your language well enough to throw something together without looking things up every thirty seconds, phase 1 isn't a two-hour sprint — it's a three-day tutorial hunt. Which is exactly the thing you were trying to escape.&lt;/p&gt;

&lt;p&gt;I wrote a whole separate post about this fundamentals half: &lt;a href="https://dev.to/cn8001/the-hidden-cost-of-framework-first-thinking-3ko1"&gt;"The Hidden Cost of Framework-First Thinking."&lt;/a&gt;. Short version: reach for a framework &lt;em&gt;after&lt;/em&gt; you've written the thing it abstracts away at least once, not before. The same principle applies here — you earn the right to prototype fast by having written the boring, foundational version first. Once that knowledge is in your hands, the ugliest-possible-version is an hour, not three days.&lt;/p&gt;

&lt;p&gt;So the order matters. &lt;strong&gt;Fundamentals first. Framework second. AI third.&lt;/strong&gt; Get any of those in the wrong order and the whole stack collapses into a different flavour of tutorial hell.&lt;/p&gt;

&lt;p&gt;The order matters because of the symmetric case. Without language fundamentals and a framework you trust, AI output has nothing to land on. You can't verify what you don't understand, and you end up stacking generated code on top of generated code, chasing bugs that move every time you regenerate. That's not a problem with the AI — the tool is fine, it's the ground underneath that's missing. It's the same pattern as copy-pasting Stack Overflow without reading the answer first: the shortcut only works if you already understand what it's shortcutting. Once that's true, the same AI goes from noise to leverage overnight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed without understanding is just faster mistakes. Speed with it is leverage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're reading this and thinking &lt;em&gt;"this sounds great, let me go start prototyping fast"&lt;/em&gt; — maybe. If you know your language well enough to catch &lt;code&gt;===&lt;/code&gt; when it should be &lt;code&gt;hash_equals()&lt;/code&gt;, yes. If you don't yet, do that first. It's the best investment you'll ever make in your own velocity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Concrete practices
&lt;/h2&gt;

&lt;p&gt;Enough philosophy. Here are the rules I actually follow, cribbed together from a year of getting most of this wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-box phase 1 to one sprint.&lt;/strong&gt; Whatever "one sprint" means in your calendar — for me it's usually a focused afternoon, for you it might be a weekend. The point is the bound, not the length. If nothing resembling an answer exists at the end of that window, the problem isn't effort. It's either the approach or the problem statement itself. Do not extend the sprint "just a bit more" — that's how prototypes become tutorial hell. End the sprint, look at what you have, make a decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best case scenario only.&lt;/strong&gt; Phase 1 is about the best-case scenario, nothing else. No auth. No error handling. No input validation. Hardcoded credentials if they make the demo shorter. If even the happy path is boring when it works, the idea doesn't deserve a phase 2 — and you'll have learned that in a day instead of a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rapid prototyping is not "skip testing".&lt;/strong&gt; This one needs saying out loud because I know how the rest of this post sounds. Phase 1 compresses the discovery phase, not the quality phase. Once an idea graduates to "make it right", you still write tests, still handle errors, still watch real users hit the rough edges. GameHub is in beta when I am writing this article. Phase 1 told me the idea worked. Phase 2 is sitting behind a small door while I fix whatever the first handful of players break. Cutting phase 1 short gives you clarity. Cutting phase 2 short gives you embarrassment.&lt;/p&gt;




&lt;h2&gt;
  
  
  It's about your future self, not your code
&lt;/h2&gt;

&lt;p&gt;Here's what this whole thing is really about.&lt;/p&gt;

&lt;p&gt;Solo devs talk a lot about "discipline" and "focus" as if the hardest part of shipping software alone is making yourself work harder. It isn't. The hard part is that the version of you who starts a project and the version of you who has to finish it are &lt;em&gt;not the same person&lt;/em&gt;. Your future self is going to be tired, distracted, bored, and — most importantly — already thinking about the next idea. That's not a character flaw. That's just how creativity runs.&lt;/p&gt;

&lt;p&gt;Everything in this post is a process for protecting that future self from themselves.&lt;/p&gt;

&lt;p&gt;Time-boxing phase 1 is a promise you make to the future-you who would otherwise spend three weeks on setup for an idea that was never going to work. The happy-path-only rule is a promise to the future-you who would otherwise get stuck wiring input validation for a demo nobody is going to run. Rapid prototyping is a promise to the future-you who would otherwise look at an unfinished project with shame and quietly close the tab.&lt;/p&gt;

&lt;p&gt;And the second self — the voice that wants a new project every time the current one slows down — isn't something you fight. It's the same voice that originally got you into this. It's the part of you still reading the frontier, still noticing where the interesting problems live. You don't want to silence that voice. You want to give it a system where being right or wrong costs almost nothing, so it can keep speaking.&lt;/p&gt;

&lt;p&gt;This is why solo devs need a development model at all. Not because the code quality matters in the way it does for a ten-person team. Because nobody else is going to stop your past self from signing your future self up for a three-week tutorial marathon on behalf of an idea that'll die in the first afternoon. You're the whole reviewing committee. You have to install your own guardrails.&lt;/p&gt;

&lt;p&gt;The methodology isn't really about the methodology. It's about keeping faith with the version of you who hasn't shown up yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you want to see any of this in action
&lt;/h2&gt;

&lt;p&gt;Two live things, one devlog series, and an open offer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PointArt&lt;/strong&gt; — the framework that started all this. Zero dependencies, attribute-driven, Spring-shaped, runs on the cheapest shared hosting you can find. Live at &lt;a href="https://pointartframework.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=prelaunch_2026" rel="noopener noreferrer"&gt;pointartframework.com&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PointArt Devlog&lt;/strong&gt; — this post is one of a series. The others cover how the framework got built, when &lt;em&gt;not&lt;/em&gt; to reach for a framework at all, the self-updater, webhooks, and a handful of other posts I'll keep adding to. They all live at &lt;a href="https://dev.to/cn8001/series/37622"&gt;PointArt Devlog Series&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GameHub — DotCom Broker 98&lt;/strong&gt; — the deckbuilder the second half of this post is about. It's behind a key-based beta at &lt;a href="https://gamehub.pointartframework.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=prelaunch_2026" rel="noopener noreferrer"&gt;gamehub.pointartframework.com&lt;/a&gt;. &lt;strong&gt;If you want a key, just ask.&lt;/strong&gt; Drop a comment below, or email me at &lt;code&gt;info@pointartframework.com&lt;/code&gt; — I'm handing them out one at a time to anyone curious enough to poke at it.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And honestly: if you've got your own second self nagging at you right now, I'd rather hear which project it's pointing at than whether you liked this post. Tell me in the comments&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>beginners</category>
      <category>productivity</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The Hidden Cost of Framework-First Thinking</title>
      <dc:creator>Canercan Demir</dc:creator>
      <pubDate>Fri, 03 Apr 2026 08:11:31 +0000</pubDate>
      <link>https://dev.to/cn8001/the-hidden-cost-of-framework-first-thinking-3ko1</link>
      <guid>https://dev.to/cn8001/the-hidden-cost-of-framework-first-thinking-3ko1</guid>
      <description>&lt;p&gt;Frameworks are good for more than just boilerplate. They encode decisions: how to structure a project, where logic belongs, how to handle requests. A developer picking up Laravel or Spring for the first time isn't just getting free code — they're inheriting years of hard-won conventions. That's valuable. It means a junior and a senior on the same team are solving the same problem in the same -almost- shape.&lt;/p&gt;

&lt;p&gt;But "frameworks are useful" doesn't mean "always use a framework." Knowing when &lt;em&gt;not&lt;/em&gt; to reach for one is as important as knowing how to use one.&lt;/p&gt;




&lt;h2&gt;
  
  
  When you're still learning the language
&lt;/h2&gt;

&lt;p&gt;This is the one that gets skipped most often, and causes the most damage later.&lt;/p&gt;

&lt;p&gt;When the only mental model is &lt;em&gt;Laravel does it this way&lt;/em&gt;, it's not really programming — it's copying at a higher level. Instead of copying Stack Overflow snippets, copying framework patterns. The abstraction is more sophisticated, but the understanding underneath is the same. When a bug appears outside the framework's happy path, or something it doesn't support cleanly is needed, there's nothing to fall back on.&lt;/p&gt;

&lt;p&gt;A concrete example: webhook signature verification.&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;// ❌ What you might write if you only know framework routing&lt;/span&gt;
&lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sha256='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&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;$expected&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$received&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Vulnerable to timing attacks&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ What learning the language teaches you&lt;/span&gt;
&lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sha256='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&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;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$received&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hash_equals()&lt;/code&gt; instead of &lt;code&gt;===&lt;/code&gt;. This is a language-level security detail that prevents timing attacks. No framework teaches this — it's just PHP. Learning PHP only through a framework, a developer might write &lt;code&gt;===&lt;/code&gt; and never know it was wrong.&lt;/p&gt;

&lt;p&gt;Learn the language first. Write raw SQL before using an ORM. Handle routing yourself before adding a router. Not forever — just long enough to see what the abstraction is actually doing for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  When the project is small enough to not need one
&lt;/h2&gt;

&lt;p&gt;A framework has a cost beyond file size or boot time. The mental overhead of fitting your problem into its model is real.&lt;/p&gt;

&lt;p&gt;For a 200-line script, a standalone endpoint, or a cron job that reads a file and sends an email — that cost doesn't pay off. A script that runs once a day and calls one external service doesn't need routing, DI containers, or a migration system. It needs to work.&lt;/p&gt;

&lt;p&gt;The same principle applies to pulling in packages. PointArt's self-updater downloads release zips from GitHub with no HTTP client library:&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;// ❌ Reaching for a package by default&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GuzzleHttp\Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$zip&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$zipUrl&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;getBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ PHP already handles this natively&lt;/span&gt;
&lt;span class="nv"&gt;$ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;stream_context_create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'http'&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;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
&lt;span class="nv"&gt;$zip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$zipUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Knowing the language means knowing when the standard library is enough — and not adding a dependency graph to solve a problem that was already solved.&lt;/p&gt;

&lt;p&gt;The question isn't &lt;em&gt;could I use a framework here?&lt;/em&gt; It's &lt;em&gt;does this problem have enough surface area that shared conventions help me manage it?&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When the framework's model doesn't fit your problem
&lt;/h2&gt;

&lt;p&gt;Frameworks are designed around specific problem shapes. A web framework expects HTTP request/response cycles. An MVC framework expects controllers, models, views.&lt;/p&gt;

&lt;p&gt;If your project doesn't fit that shape — a long-running daemon, a CLI tool, a data pipeline, a batch processor — you spend as much effort fighting the framework as building the thing.&lt;/p&gt;

&lt;p&gt;When I built &lt;a href="https://pointartframework.com" rel="noopener noreferrer"&gt;PointArt&lt;/a&gt;, I had to make this call explicitly: no middleware system, no async, single-process, designed for shared hosting. Not oversights — deliberate limits because the target problem is the web request/response cycle on constrained hosting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The AI angle
&lt;/h2&gt;

&lt;p&gt;There's a new version of the "framework without language knowledge" problem: using AI to generate framework code without understanding either.&lt;/p&gt;

&lt;p&gt;With enough prompting you can build something that looks like a working application. Frameworks help AI here — it's seen a lot of Laravel and Rails, so it generates plausible-looking code. But when something goes wrong, and it will, you have no model of what correct looks like. You can't debug what you can't read. You can't maintain what you don't understand.&lt;/p&gt;

&lt;p&gt;AI is a strong tool for developers who already know what they're doing. It fills in boilerplate fast. But the understanding it skips is exactly what you'll need when the generated code misbehaves.&lt;/p&gt;




&lt;h2&gt;
  
  
  The practical signals
&lt;/h2&gt;

&lt;p&gt;Use a framework when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple developers need shared conventions across a long-lived codebase&lt;/li&gt;
&lt;li&gt;The problem shape fits the framework's model well&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're still learning the language fundamentals&lt;/li&gt;
&lt;li&gt;The project is small enough that the model costs more than it saves&lt;/li&gt;
&lt;li&gt;The deploy target rules it out&lt;/li&gt;
&lt;li&gt;Your problem shape doesn't match the framework's assumptions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to avoid frameworks. It's to know what they're doing — so you can choose when to use them, and know what to do when they stop working.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I've been writing about building &lt;a href="https://pointartframework.com" rel="noopener noreferrer"&gt;PointArt&lt;/a&gt; — a zero-dependency PHP micro-framework — from scratch. If you're curious about what it looks like to make these decisions at the framework level, you can take a look at &lt;a href="https://dev.to/cn8001/series/37622"&gt;PointArt Devlog Series&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>programming</category>
      <category>webdev</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How I Built a Self-Updater With GitHub Releases</title>
      <dc:creator>Canercan Demir</dc:creator>
      <pubDate>Fri, 27 Mar 2026 18:38:20 +0000</pubDate>
      <link>https://dev.to/cn8001/how-i-built-a-self-updater-with-github-releases-2j15</link>
      <guid>https://dev.to/cn8001/how-i-built-a-self-updater-with-github-releases-2j15</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://pointartframework.com" rel="noopener noreferrer"&gt;PointArt&lt;/a&gt; — a PHP micro-framework modelled after Spring Boot's programming model. Attribute-based routing, dependency injection, an ORM, repositories — all in plain PHP, no Composer required.&lt;/p&gt;

&lt;p&gt;The previous articles in this series covered the framework itself and how I turned GitHub into a headless CMS for the docs site. This one is about a different kind of problem: distribution.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pointartframework.com/changelog" rel="noopener noreferrer"&gt;PointArt v1.1.0&lt;/a&gt; added CORS and CSRF support. CORS headers are opt-in via &lt;code&gt;.env&lt;/code&gt; — useful if you're building an API frontend. CSRF protection is on by default for all POST form requests, with per-route opt-out for webhooks. Both are security features, the kind of thing you actually want users to be running.&lt;/p&gt;

&lt;p&gt;But here's the problem: how do users get the update?&lt;/p&gt;

&lt;p&gt;There's no &lt;code&gt;composer update&lt;/code&gt; (and if it ever comes, it will be totally optional). The framework is just files — you clone it and deploy it. Updating means manually downloading a zip, figuring out which files changed, and copying them over without breaking your own &lt;code&gt;app/&lt;/code&gt; code. In practice, most people don't bother. They stay on whatever version they cloned.&lt;/p&gt;

&lt;p&gt;That's a bad place to be when the update contains security features. So I built a self-updater directly into the framework — browser-based, no terminal required, pulls from GitHub Releases.&lt;/p&gt;

&lt;p&gt;This article is about how that works: the system design, the tradeoffs, and how it all fits into a codebase with zero dependencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Constraint: Zero Dependencies
&lt;/h2&gt;

&lt;p&gt;Composer is great, but not everyone has it available — shared hosting environments vary a lot, and more importantly, not every project needs it. Optional Composer support is on the roadmap, but zero-dependency will always remain a valid choice. If PHP 8.1+ is available, PointArt runs.&lt;/p&gt;

&lt;p&gt;That constraint shapes everything about how the updater had to be built. No CLI helpers. Just what PHP ships with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;file_get_contents()&lt;/code&gt; with an HTTP context — for hitting the GitHub API and downloading zips&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ZipArchive&lt;/code&gt; — for extracting the release archive&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;copy()&lt;/code&gt;, &lt;code&gt;scandir()&lt;/code&gt;, &lt;code&gt;mkdir()&lt;/code&gt; — for the file sync&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hash_equals()&lt;/code&gt; — for constant-time secret comparison&lt;/li&gt;
&lt;li&gt;Sessions — for auth state between the login POST and the update page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire dependency surface. If these exist on a host, the updater works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Design
&lt;/h2&gt;

&lt;p&gt;The updater intercepts requests before the application router runs — so it works even if the framework itself is partially broken, which matters when you're about to overwrite framework files.&lt;/p&gt;

&lt;p&gt;The full flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User visits /pointart/update
    → Not authed? Show login form
    → Authed? Call GitHub API, show version check page
        → Click "Update Now"
            → Download zip from GitHub
            → Backup existing files
            → Sync new files (skip protected paths)
            → Clear route cache
            → Show result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Version Tracking
&lt;/h2&gt;

&lt;p&gt;The installed version is stored in a plain text file: &lt;code&gt;framework/VERSION&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. One line. The updater reads it with &lt;code&gt;file_get_contents&lt;/code&gt;, strips whitespace, and extracts the semver string with a regex. If the file doesn't exist, it defaults to &lt;code&gt;0.0.0&lt;/code&gt; — which means any real release will be considered an update.&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getCurrentVersion&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="nb"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION_FILE&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;'0.0.0'&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="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION_FILE&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;$raw&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="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'0.0.0'&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="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/'&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="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&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;return&lt;/span&gt; &lt;span class="nv"&gt;$matches&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="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the version in a plain file means it survives updates — it gets overwritten by whatever the new release ships — and it's readable by anything without parsing JSON or querying a database.&lt;/p&gt;




&lt;h2&gt;
  
  
  Talking to GitHub Releases
&lt;/h2&gt;

&lt;p&gt;The updater doesn't use webhooks or a custom release server. It just calls the public GitHub Releases API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.github.com/repos/Cn8001/PointArt/releases
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns a JSON array of releases, newest first. Each object has &lt;code&gt;tag_name&lt;/code&gt; for the version string, &lt;code&gt;zipball_url&lt;/code&gt; to download the archive, &lt;code&gt;body&lt;/code&gt; for release notes, and &lt;code&gt;prerelease&lt;/code&gt; — a boolean set from the GitHub release UI. The updater iterates and picks the first release matching the selected channel:&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$releases&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$isPrerelease&lt;/span&gt; &lt;span class="o"&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;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'prerelease'&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;$channel&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'stable'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$isPrerelease&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$channel&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'dev'&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="nv"&gt;$isPrerelease&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'version'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tag_name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'zip_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'zipball_url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'notes'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&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="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;strong&gt;stable&lt;/strong&gt; channel skips anything marked as prerelease on GitHub. The &lt;strong&gt;dev&lt;/strong&gt; channel does the opposite — only prereleases. No special tag naming convention required. The prerelease checkbox on the GitHub release UI is the only thing that controls channel membership.&lt;/p&gt;

&lt;p&gt;Version comparison uses PHP's built-in &lt;code&gt;version_compare()&lt;/code&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;version_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'version'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&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="c1"&gt;// already up to date&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The File Sync: Safe by Default
&lt;/h2&gt;

&lt;p&gt;This is the part that has to be right. The sync logic walks the extracted release directory and copies files into place — but with two layers of protection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protected paths are never touched:&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;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;PROTECTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'.env'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'.env.example'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'cache'&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;code&gt;app/&lt;/code&gt; is your controllers, models, views. &lt;code&gt;.env&lt;/code&gt; is your config. &lt;code&gt;cache/&lt;/code&gt; is runtime state. SQLite files are also skipped anywhere they're found — by extension, not by path:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;pathinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PATHINFO_EXTENSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'sqlite'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every overwritten file is backed up first:&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="c1"&gt;// Backup existing file&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;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dest&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="nb"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$bak&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$errors&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Failed to backup &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="s2"&gt;"&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;// don't overwrite if we couldn't back up&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Copy new file&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;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$dest&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$errors&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Failed to copy &lt;/span&gt;&lt;span class="nv"&gt;$rel&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the backup fails, the file isn't overwritten. If any errors occur, the result page reports them and the backup directory stays in place at &lt;code&gt;cache/update-backup-{version}/&lt;/code&gt; so you can restore manually.&lt;/p&gt;

&lt;p&gt;On a clean update, the backup directory is removed at the end — no leftover clutter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Post-Update
&lt;/h2&gt;

&lt;p&gt;PointArt scans &lt;code&gt;app/&lt;/code&gt; once and serializes the route + service registry to &lt;code&gt;cache/registry.ser&lt;/code&gt;. Every subsequent request reads from that cache. After an update, the cache needs to be cleared so the next request rebuilds it.&lt;/p&gt;

&lt;p&gt;The updater calls this directly:&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="nc"&gt;ClassLoader&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;clearCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which just deletes the file. No fancy invalidation, no versioning. The next request rebuilds from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;Here's the updater from a user's perspective:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add two lines to &lt;code&gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Visit &lt;code&gt;/pointart/update&lt;/code&gt; in a browser&lt;/li&gt;
&lt;li&gt;Enter the secret&lt;/li&gt;
&lt;li&gt;See current version, latest version, release notes&lt;/li&gt;
&lt;li&gt;Click Update&lt;/li&gt;
&lt;li&gt;Done&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From a deployment perspective, you'd enable the updater when you want to check for updates and disable it afterwards. It's not meant to be always-on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What this approach gets right:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Works on shared hosting with no CLI access&lt;/li&gt;
&lt;li&gt;User's code and data are never touched&lt;/li&gt;
&lt;li&gt;Backup before overwrite — something can always go wrong&lt;/li&gt;
&lt;li&gt;Stable/dev channels without special tag naming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rollback — you'd have to restore from the backup directory manually&lt;/li&gt;
&lt;li&gt;Differential updates — always downloads the full release zip&lt;/li&gt;
&lt;li&gt;Auto-updates on a schedule — it's manual, on demand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a framework at this scale and distribution model, those tradeoffs are fine. The goal was to make applying an update take two minutes instead of twenty.&lt;/p&gt;




&lt;p&gt;PointArt is open source under the Mozilla Public License 2.0. If you want to dig into the full implementation, the repo and documentation are at &lt;a href="https://pointartframework.com" rel="noopener noreferrer"&gt;pointartframework.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>How I Turned GitHub into a Headless CMS</title>
      <dc:creator>Canercan Demir</dc:creator>
      <pubDate>Sun, 22 Mar 2026 10:35:22 +0000</pubDate>
      <link>https://dev.to/cn8001/how-i-turned-github-into-a-headless-cms-ded</link>
      <guid>https://dev.to/cn8001/how-i-turned-github-into-a-headless-cms-ded</guid>
      <description>&lt;p&gt;After publishing my PHP micro-framework &lt;a href="https://github.com/Cn8001/PointArt" rel="noopener noreferrer"&gt;PointArt&lt;/a&gt;, I built &lt;a href="https://pointartframework.com" rel="noopener noreferrer"&gt;a website&lt;/a&gt; for it. However, the site needed some dynamic content — especially &lt;a href="https://pointartframework.com/changelog" rel="noopener noreferrer"&gt;the changelog&lt;/a&gt; and roadmap — to update the moment something changes on GitHub.&lt;/p&gt;

&lt;p&gt;My first instinct was an admin panel. Then I stopped myself — the content already lives on GitHub, why enter it for the second time? It also opens a door for human error. So I made GitHub the CMS instead of an admin panel and used webhooks to keep a local database in sync automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub (push or release event)
  → POST /hooks/* (HMAC-verified)
    → Parse payload
      → Upsert / delete DB rows
        → Website reads DB on page load
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two content types, two sync strategies:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Changelog&lt;/td&gt;
&lt;td&gt;GitHub Releases&lt;/td&gt;
&lt;td&gt;Incremental — insert/update/delete per event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roadmap&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONTRIBUTING.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full sync — re-fetch + delete-all + re-insert on every push&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The website never calls the GitHub API at request time. It reads from the local database; GitHub keeps that data fresh.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verifying the Signature
&lt;/h2&gt;

&lt;p&gt;Every GitHub webhook includes an &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; header — HMAC-SHA256 of the raw body signed with your secret. Verify it before anything else.&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;verifySignature&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;$rawBody&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;$secret&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;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sha256='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$received&lt;/span&gt; &lt;span class="o"&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_X_HUB_SIGNATURE_256'&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$received&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// timing-safe&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas here: read the raw body &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;json_decode()&lt;/code&gt; (HMAC is over the exact bytes GitHub sent), and use &lt;code&gt;hash_equals()&lt;/code&gt; not &lt;code&gt;===&lt;/code&gt; to avoid timing attacks.&lt;/p&gt;

&lt;p&gt;GitHub also sends a &lt;strong&gt;ping&lt;/strong&gt; event when the webhook is first created. Handle it or GitHub marks the endpoint as failed:&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="nv"&gt;$rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php://input'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$payload&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;$rawBody&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="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;http_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'zen'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;// ping event&lt;/span&gt;
    &lt;span class="k"&gt;echo&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;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pong'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In PointArt website, this lives in a &lt;code&gt;HookController&lt;/code&gt; with &lt;code&gt;#[Router]&lt;/code&gt; / &lt;code&gt;#[Route]&lt;/code&gt; attributes:&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="na"&gt;#[Router(name: 'webhook', path: '/hooks')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HookController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Wired&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;ReleaseRepository&lt;/span&gt; &lt;span class="nv"&gt;$releaseRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Wired&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;RoadmapRepository&lt;/span&gt; &lt;span class="nv"&gt;$roadmapRepository&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;function&lt;/span&gt; &lt;span class="n"&gt;verifySignature&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;$rawBody&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;$secret&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WEBHOOK_SECRET'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sha256='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$received&lt;/span&gt; &lt;span class="o"&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_X_HUB_SIGNATURE_256'&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$received&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;h2&gt;
  
  
  Syncing the Changelog (Incremental)
&lt;/h2&gt;

&lt;p&gt;Release events carry an &lt;code&gt;action&lt;/code&gt; field: &lt;code&gt;published&lt;/code&gt;, &lt;code&gt;edited&lt;/code&gt;, or &lt;code&gt;deleted&lt;/code&gt;. Map them to DB operations:&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="nv"&gt;$action&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nv"&gt;$release&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'release'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nv"&gt;$releaseId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$release&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'deleted'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;deleteByReleaseId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$releaseId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'published'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'edited'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'released'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;deleteByReleaseId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$releaseId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// delete + insert = simple upsert&lt;/span&gt;
    &lt;span class="nf"&gt;insertRelease&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'release_id'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$releaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'tag_name'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tag_name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tag_name'&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="nv"&gt;$release&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="s1"&gt;'published_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$release&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="s1"&gt;'author'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'author'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'login'&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;Delete + insert instead of UPDATE keeps it simple and naturally idempotent (more on that below).&lt;/p&gt;

&lt;p&gt;In PointArt this maps to a repository with &lt;code&gt;#[Query]&lt;/code&gt; attributes — method signatures auto-generate their implementations:&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="na"&gt;#[Service]&lt;/span&gt;
&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReleaseRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Repository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$entityClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Release&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Query('SELECT * FROM releases ORDER BY published_at DESC')]&lt;/span&gt;
    &lt;span class="k"&gt;abstract&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;findAllOrderedByDate&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="na"&gt;#[Query('DELETE FROM releases WHERE release_id = ?')]&lt;/span&gt;
    &lt;span class="k"&gt;abstract&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;deleteByReleaseId&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;$releaseId&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Syncing the Roadmap (Full Sync from Markdown)
&lt;/h2&gt;

&lt;p&gt;For the roadmap I use a different strategy: the push event is just a trigger. I re-fetch &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; from GitHub's raw CDN and do a full delete + re-insert.&lt;/p&gt;

&lt;p&gt;Why not parse the diff from the payload? Because fetching the whole file is simpler and I don't need partial updates — every push gives a fresh, complete view.&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="nv"&gt;$url&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://raw.githubusercontent.com/yourname/repo/master/CONTRIBUTING.md'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;stream_context_create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'http'&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;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]]));&lt;/span&gt;

&lt;span class="c1"&gt;// Full sync: wipe and re-insert&lt;/span&gt;
&lt;span class="nv"&gt;$roadmapRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;deleteAll&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="nf"&gt;parseContributing&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="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$entry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$entry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'description'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$roadmapRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entry&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 parser scans for &lt;code&gt;###&lt;/code&gt; headings inside a &lt;code&gt;## What to Contribute&lt;/code&gt; section:&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;parseContributing&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;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&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;$content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nv"&gt;$inSection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$title&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="nv"&gt;$descLines&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;$lines&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&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="nv"&gt;$inSection&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="nb"&gt;stripos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'## What to Contribute'&lt;/span&gt;&lt;span class="p"&gt;)&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="nv"&gt;$inSection&lt;/span&gt; &lt;span class="o"&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;continue&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&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="k"&gt;break&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="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&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="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;$title&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="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;implode&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;$descLines&lt;/span&gt;&lt;span class="p"&gt;))];&lt;/span&gt;
            &lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;=&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;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="nv"&gt;$descLines&lt;/span&gt; &lt;span class="o"&gt;=&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="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;$title&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$line&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$line&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="nv"&gt;$descLines&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$line&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="nv"&gt;$title&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="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;implode&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;$descLines&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;$items&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;h2&gt;
  
  
  A Few Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Webhooks only fire on future events.&lt;/strong&gt; When you first deploy, the DB is empty, keep in mind!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub retries on non-2xx.&lt;/strong&gt; If your handler throws a 500, GitHub retries. The delete + insert pattern makes handlers naturally idempotent, so retries are harmless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;raw.githubusercontent.com&lt;/code&gt; for files, not the API.&lt;/strong&gt; The raw CDN has no rate limit for public repos. The GitHub API is limited to 60 unauthenticated requests/hour.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Publish a GitHub release → changelog updates automatically&lt;/li&gt;
&lt;li&gt;Edit &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; and push → roadmap refreshes&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The same pattern could extend further — maybe devlogs, ideas, or any other content that already lives in a GitHub repository. If GitHub is already your source of truth, the webhook just keeps everything else in sync.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>github</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>I built a PHP framework with Spring Boot style - zero dependencies, dynamic ORM, attribute-based routing (works on shared host).</title>
      <dc:creator>Canercan Demir</dc:creator>
      <pubDate>Wed, 18 Mar 2026 11:14:58 +0000</pubDate>
      <link>https://dev.to/cn8001/i-built-a-php-framework-with-spring-boot-style-zero-dependencies-dynamic-orm-attribute-based-5c10</link>
      <guid>https://dev.to/cn8001/i-built-a-php-framework-with-spring-boot-style-zero-dependencies-dynamic-orm-attribute-based-5c10</guid>
      <description>&lt;p&gt;I was using Spring Boot and it was my default selection until one client wanted to build a simple website. I had started coding with PHP but hadn't used it for a while. Then, I remembered the simplicity and minimalism of mixing PHP, HTML, CSS, and JS into one file (or separate files with .js etc), and how easy and cheap the hosting management was. But I didn't want to leave Spring's features behind, like JPA and dependency injection.&lt;/p&gt;

&lt;p&gt;So, I built a microframework with zero dependency that even works on shared hosting.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website &amp;amp; Docs:&lt;/strong&gt; &lt;a href="https://pointartframework.com" rel="noopener noreferrer"&gt;pointartframework.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repo:&lt;/strong&gt; &lt;a href="https://github.com/Cn8001/PointArt" rel="noopener noreferrer"&gt;Cn8001/PointArt&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site itself is using PointArt and resides on shared hosting. For more documentation please visit the website.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PointArt&lt;/strong&gt; is a PHP micro-framework that brings Spring Boot's attribute-based style to PHP 8.1 (PHP 8.1+ since it introduced attributes, and PDO driver is required):&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="na"&gt;#[Router(name: 'user', path: '/user')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Wired]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;UserRepository&lt;/span&gt; &lt;span class="nv"&gt;$userRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Route('/list', HttpMethod::GET)]&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;index&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="nc"&gt;Renderer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.list'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;userRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Route('/show/{id}', HttpMethod::GET)]&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;show&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;$id&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;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&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;$user&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Renderer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;httpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Route('/create', HttpMethod::POST)]&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;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;RequestParam&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;RequestParam&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;
    &lt;span class="p"&gt;)&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="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Renderer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&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 repository pattern generates query implementations from method names — just like Spring Data JPA. If you need custom SQL it also allows it:&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;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Repository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$entityClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;abstract&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;findByName&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;$name&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;abstract&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;findOneByEmail&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;$email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;abstract&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;existsByEmail&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;$email&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;abstract&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;countByActiveTrue&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Custom SQL when you need it&lt;/span&gt;
    &lt;span class="na"&gt;#[Query("SELECT COUNT(*) FROM users")]&lt;/span&gt;
    &lt;span class="k"&gt;abstract&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;countAll&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&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;Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;#[Router]&lt;/code&gt; / &lt;code&gt;#[Route]&lt;/code&gt; — attribute-based routing with path params and query string support&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#[Wired]&lt;/code&gt; — property injection, no constructor boilerplate&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#[Entity]&lt;/code&gt; / &lt;code&gt;#[Column]&lt;/code&gt; / &lt;code&gt;#[Id]&lt;/code&gt; — ORM for SQLite, MySQL, PostgreSQL&lt;/li&gt;
&lt;li&gt;Spring Data-style dynamic finders + &lt;code&gt;#[Query]&lt;/code&gt; for raw SQL&lt;/li&gt;
&lt;li&gt;Route + service registry is cached — no Reflection overhead on every request&lt;/li&gt;
&lt;li&gt;No Composer, no build step, no CLI — literally copy files to a server and go (that is the reason why I did not use a full framework.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's been a fun project to build from scratch (the dynamic repository finder parser was the trickiest part). Would love any feedback and open to contributions. You can see the future ideas on the website.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
