<?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: Web Pioneer</title>
    <description>The latest articles on DEV Community by Web Pioneer (@webpioneer).</description>
    <link>https://dev.to/webpioneer</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%2F4038061%2Ffbe78f44-cefa-4887-b447-a77232af578a.png</url>
      <title>DEV Community: Web Pioneer</title>
      <link>https://dev.to/webpioneer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webpioneer"/>
    <language>en</language>
    <item>
      <title>Self-Hosting Your Ops Stack on One VPS: Metrics, Errors, CI and Backups Without a SaaS Bill</title>
      <dc:creator>Web Pioneer</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:45:01 +0000</pubDate>
      <link>https://dev.to/webpioneer/self-hosting-your-ops-stack-on-one-vps-metrics-errors-ci-and-backups-without-a-saas-bill-59a6</link>
      <guid>https://dev.to/webpioneer/self-hosting-your-ops-stack-on-one-vps-metrics-errors-ci-and-backups-without-a-saas-bill-59a6</guid>
      <description>&lt;p&gt;Small engineering teams reach a point where the monthly tooling bill starts to look strange next to the infrastructure bill. Metrics, dashboards, error tracking, CI, uptime monitoring and a business system can easily cost more per month than every server they run on.&lt;/p&gt;

&lt;p&gt;Self-hosting is not automatically the answer — it moves cost from a subscription line into your team's time, and that trade is only good if the operational burden stays small. This article is about keeping it small: what genuinely fits on one modest VPS, how to size it, the failures that catch people, and where the line is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fits on one box
&lt;/h2&gt;

&lt;p&gt;A single VPS with 8&amp;nbsp;vCPU and 16&amp;nbsp;GB of RAM comfortably runs the following, with headroom:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Realistic memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;Metrics collection and storage&lt;/td&gt;
&lt;td&gt;1–2&amp;nbsp;GB, grows with cardinality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grafana&lt;/td&gt;
&lt;td&gt;Dashboards and alerting UI&lt;/td&gt;
&lt;td&gt;200–400&amp;nbsp;MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blackbox exporter&lt;/td&gt;
&lt;td&gt;External uptime and TLS expiry probes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;The memory trap.&lt;/strong&gt; Self-hosted error-tracking platforms are not a single service — they are a stack of web workers, background workers, a relay, a database, a cache and a queue, and the default compose files are tuned for a much larger machine than yours. This one component will consume more RAM than everything else combined. Budget for it first, or discover it when the kernel's OOM killer starts choosing victims at 3am.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Budget memory before you install anything
&lt;/h2&gt;

&lt;p&gt;The single most common failure mode is not a service crashing — it is the OOM killer terminating whichever process happened to be largest, which is frequently your database. Two mitigations, both cheap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Cap every container explicitly.&lt;/strong&gt; Unbounded containers will expand until the host has nothing left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1g&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Add swap even on an SSD box.&lt;/strong&gt; Swap is not a substitute for RAM, but it converts an instant OOM kill into a slow period you can alert on and react to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 4G /swapfile
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /swapfile
mkswap /swapfile &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/fstab
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; vm.swappiness&lt;span class="o"&gt;=&lt;/span&gt;10   &lt;span class="c"&gt;# prefer RAM, use swap as a cushion&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then alert on swap usage above zero. On a healthy box it stays at zero, so any sustained swap is an early warning that you are one deploy away from an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three failures that catch almost everyone
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Failure 1: the firewall silently breaks certificate renewal
&lt;/h3&gt;

&lt;p&gt;You harden the box, allow only 443 and SSH, and everything works. Sixty days later every service on the host goes untrusted simultaneously.&lt;/p&gt;

&lt;p&gt;The HTTP-01 certificate challenge requires the certificate authority to reach &lt;strong&gt;port 80&lt;/strong&gt;. If your firewall drops it, renewal fails — quietly, from a cron job nobody reads, for weeks. The first symptom is a browser warning on every subdomain at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw allow 80/tcp        &lt;span class="c"&gt;# required for HTTP-01, even if you redirect everything to 443&lt;/span&gt;
ufw allow 443/tcp
certbot renew &lt;span class="nt"&gt;--force-renewal&lt;/span&gt; &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two durable fixes: monitor certificate expiry as a metric rather than trusting the renewal job, and prefer DNS-01 challenges if you genuinely cannot open port 80. The blackbox exporter exposes &lt;code&gt;probe_ssl_earliest_cert_expiry&lt;/code&gt; — alert at 21 days remaining and this class of incident disappears permanently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 2: stale resolvers make monitoring lie
&lt;/h3&gt;

&lt;p&gt;A subtler one. Some hosting providers ship images preconfigured with their own recursive resolvers, and those resolvers can serve stale records long after you have changed DNS.&lt;/p&gt;

&lt;p&gt;The consequence is specific and nasty: your uptime probes resolve a domain to an address that no longer serves it. The monitoring system reports the site as down while it is perfectly healthy — or, far worse, reports it as up while pointing at a decommissioned host. You end up debugging an application that has nothing wrong with it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/netplan/01-netcfg.yaml&lt;/span&gt;
&lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;ethernets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;eth0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nameservers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1.1.1.1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;8.8.8.8&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;netplan apply
resolvectl status | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'DNS Servers'&lt;/span&gt;
dig +short your-domain.com @1.1.1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; a monitoring host must resolve names the same way your users do. Pin it to public resolvers and verify after every image rebuild — provider defaults come back.Failure 3: backups the attacker can delete&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The naive design has each server push its backups to a central repository, which means every server holds credentials that can write to — and therefore delete or encrypt — that repository. One compromised host destroys every backup you have. This is precisely how ransomware incidents escalate from bad to unrecoverable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invert it.&lt;/strong&gt; The backup host &lt;em&gt;pulls&lt;/em&gt; from each server over SSH using a key restricted to a read-only rsync command. The servers hold no backup credentials at all:&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;# on the backed-up host, ~/.ssh/authorized_keys&lt;/span&gt;
&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"rsync --server --sender -vlogDtpre.iLsfxC . /path/to/data/"&lt;/span&gt;,&lt;span class="se"&gt;\&lt;/span&gt;
no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then snapshot into a deduplicating repository with retention, and — the step people skip — &lt;strong&gt;verify&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic backup /srv/pulled/hostname &lt;span class="nt"&gt;--tag&lt;/span&gt; nightly
restic forget &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 7 &lt;span class="nt"&gt;--keep-weekly&lt;/span&gt; 4 &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 6 &lt;span class="nt"&gt;--prune&lt;/span&gt;
restic check &lt;span class="nt"&gt;--read-data-subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5%   &lt;span class="c"&gt;# actually read data, not just metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unverified backup is a hypothesis. Schedule a real restore of one service to a scratch directory monthly and time it — that number is your true recovery objective, and it is always longer than anyone guesses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitor the monitoring
&lt;/h2&gt;

&lt;p&gt;A self-hosted observability stack has an obvious flaw: when the host dies, so does everything that would have told you. Close it with two cheap measures.&lt;/p&gt;

&lt;p&gt;First, an &lt;strong&gt;external&lt;/strong&gt; uptime check from a third party — a free tier is fine — that watches the monitoring host itself. Second, a dead-man's switch: your stack pings an external endpoint every few minutes, and that service alerts you when the pings &lt;em&gt;stop&lt;/em&gt;. Inverting the signal catches total failure, which normal alerting cannot.&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;# pushes only if the local health check passes&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 http://localhost:9090/-/healthy &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 https://your-deadman-endpoint/ping &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lock the perimeter
&lt;/h2&gt;

&lt;p&gt;One box now holds metrics, error traces containing request payloads, CI credentials and possibly business data. Treat it accordingly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing internal on a public port.&lt;/strong&gt; Bind services to &lt;code&gt;127.0.0.1&lt;/code&gt; and expose only the reverse proxy. A dashboard tool reachable from the internet with default credentials is a well-known way to lose a server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH keys only&lt;/strong&gt;, password authentication disabled, plus fail2ban or equivalent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication in front of every UI&lt;/strong&gt;, including the ones you assume nobody knows about. Metrics endpoints leak internal hostnames, versions and topology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets out of compose files.&lt;/strong&gt; Environment files with restrictive modes, excluded from version control, rotated when anyone leaves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unattended security updates&lt;/strong&gt; enabled, with reboot windows scheduled rather than deferred indefinitely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we do not recommend self-hosting
&lt;/h2&gt;

&lt;p&gt;Being honest about the boundary is what makes the rest credible:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Do self-host&lt;/th&gt;
&lt;th&gt;Do not&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Metrics and dashboards — stable, low-maintenance, data grows predictably&lt;/td&gt;
&lt;td&gt;Outbound email delivery — deliverability is a full-time reputation problem; use a provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error tracking — high SaaS cost per event, tolerable to operate&lt;/td&gt;
&lt;td&gt;Authentication for customers — the blast radius of getting it wrong is unbounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI for your own repositories&lt;/td&gt;
&lt;td&gt;Payments — compliance scope you do not want&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal business systems&lt;/td&gt;
&lt;td&gt;Your status page — it must survive your infrastructure failing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime probing (as a second opinion)&lt;/td&gt;
&lt;td&gt;Primary DNS — anycast networks are better and effectively free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The real cost calculation
&lt;/h2&gt;

&lt;p&gt;The subscription saving is easy to compute and easy to overstate. The honest version includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The VPS itself, plus a second small instance if you want offsite backup separation.&lt;/li&gt;
&lt;li&gt;Roughly half a day per month of routine maintenance — updates, disk pressure, retention tuning.&lt;/li&gt;
&lt;li&gt;Occasional incident time when a component surprises you, front-loaded in the first two months.&lt;/li&gt;
&lt;li&gt;The knowledge concentration risk: if exactly one person understands the stack, that is an availability problem. Write the runbook before you need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a team already running its own servers and comfortable with Linux, this trade is usually clearly positive. For a team without that baseline, a managed service is genuinely the cheaper option once you price the time correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Budget memory before installing anything; cap containers and add swap you alert on.&lt;/li&gt;
&lt;li&gt;Open port 80 for HTTP-01, and monitor certificate expiry rather than trusting the renewal cron.&lt;/li&gt;
&lt;li&gt;Pin your monitoring host to public DNS resolvers so probes see what users see.&lt;/li&gt;
&lt;li&gt;Pull backups from a host the servers cannot write to, and verify restores on a schedule.&lt;/li&gt;
&lt;li&gt;Watch the watcher with an external check and a dead-man's switch.&lt;/li&gt;
&lt;li&gt;Keep email, customer auth, payments and your status page off the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We design, deploy and operate infrastructure like this for companies across Egypt and the Gulf — including the monitoring, backup verification and incident response that make it safe to run yourself. If you want a review of your current setup before it becomes an incident, our team is available.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How much server do I need to self-host a monitoring and error-tracking stack?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a small team, 8 vCPU and 16 GB of RAM is a comfortable starting point, and the memory matters far more than the cores. Self-hosted error-tracking platforms are the dominant consumer — they run as a group of web workers, background workers, a database, a cache and a queue, and typically need 4 to 6 GB on their own. Metrics, dashboards and probes together fit in about 2 GB. Add swap and cap every container so one runaway process cannot trigger the OOM killer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my Let's Encrypt certificates stop renewing after I enabled a firewall?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The HTTP-01 challenge requires the certificate authority to reach your server on port 80. If the firewall drops it, renewals fail silently from cron and you only find out roughly sixty days later when every service on the host goes untrusted at once. Allow 80/tcp even if you redirect all traffic to HTTPS, or switch to a DNS-01 challenge. Then monitor certificate expiry as a metric and alert with about three weeks of margin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should backups be pushed from servers or pulled by the backup host?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pulled. If each server pushes, every server holds credentials that can delete or encrypt the backup repository, so a single compromised host can destroy all your backups. With a pull model the backup host connects outward using an SSH key restricted to a read-only command, and the servers hold no backup credentials at all. Add retention, and verify with periodic real restores rather than trusting that the job exited zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should I never self-host?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Outbound email delivery, customer authentication, payment processing, primary DNS, and your status page. Email deliverability is an ongoing reputation problem that a provider solves better; auth and payments carry a blast radius and compliance scope that is not worth owning; anycast DNS from a provider is better and essentially free; and a status page hosted on your own infrastructure goes down exactly when you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I get alerted when the monitoring server itself goes down?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two independent mechanisms. Use an external uptime service to watch the monitoring host from outside your network, and add a dead-man's switch — have the stack ping an external endpoint on a schedule and configure that service to alert when the pings stop rather than when they fail. Inverting the signal is what catches total host failure, which internal alerting by definition cannot report.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://web-pioneer.com/en/blog/self-hosted-ops-stack-single-vps-guide" rel="noopener noreferrer"&gt;web-pioneer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>selfhosted</category>
      <category>monitoring</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>A Local WhatsApp Gateway for Laravel Notifications: Architecture, Trade-offs and Honest Limits</title>
      <dc:creator>Web Pioneer</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:38:19 +0000</pubDate>
      <link>https://dev.to/webpioneer/a-local-whatsapp-gateway-for-laravel-notifications-architecture-trade-offs-and-honest-limits-26h0</link>
      <guid>https://dev.to/webpioneer/a-local-whatsapp-gateway-for-laravel-notifications-architecture-trade-offs-and-honest-limits-26h0</guid>
      <description>&lt;p&gt;Every business application in this region eventually needs to send a WhatsApp message. Booking confirmations, OTPs, delivery updates, invoice reminders — the channel your customers actually read.&lt;/p&gt;

&lt;p&gt;The engineering question is not "how do I send a WhatsApp message." It is "how do I keep that decision reversible." Messaging providers change, pricing changes, template approval rules change, and a project that hardcodes one provider's HTTP client across forty call sites will pay for it repeatedly.&lt;/p&gt;

&lt;p&gt;This article describes the shape we use: WhatsApp delivery sits behind Laravel's own notification abstraction, with a swappable transport underneath, developed and tested against a small self-hosted gateway on localhost. It also states plainly where that gateway is &lt;em&gt;not&lt;/em&gt; appropriate, because that part usually gets left out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the honest constraint
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Read this before you build anything.&lt;/strong&gt; There are two fundamentally different ways to send WhatsApp messages programmatically. The official &lt;strong&gt;WhatsApp Business Cloud API&lt;/strong&gt; is a supported product with documented rate limits, template approval, and an account you can be held accountable to. Community libraries that drive the &lt;strong&gt;WhatsApp Web protocol&lt;/strong&gt; from a linked device are unofficial. They are not covered by WhatsApp's terms for business messaging, the number can be restricted or banned, and there is no support path when that happens.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Our position: a local gateway is legitimate for &lt;strong&gt;development, integration testing, and internal notifications to your own team's numbers with their consent&lt;/strong&gt;. For customer-facing production messaging, use the official Cloud API. The architecture below is designed so that switching between them is a config change, not a rewrite.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;/blockquote&gt;

&lt;p&gt;If a vendor tells you an unofficial gateway is a production-grade substitute for the Cloud API, they are transferring risk to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seam: one interface, several transports
&lt;/h2&gt;

&lt;p&gt;Laravel already gives you the right abstraction. A notification declares &lt;em&gt;what&lt;/em&gt; it wants to send and &lt;em&gt;through which channel&lt;/em&gt;; it should never know how bytes reach a phone.&lt;/p&gt;

&lt;p&gt;Define a narrow contract:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Messaging&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;MessageTransport&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * @param  string $to       E.164 digits, no punctuation
     * @param  string $body     Plain text
     * @return string           Provider message id, for correlation
     * @throws TransportException
     */&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;send&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;$to&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;$body&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bind whichever implementation the environment asks for:&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;// app/Providers/MessagingServiceProvider.php&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;register&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MessageTransport&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;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$app&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="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'messaging.driver'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s1"&gt;'cloud_api'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CloudApiTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'messaging.cloud_api'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="s1"&gt;'gateway'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LocalGatewayTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'messaging.gateway'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="s1"&gt;'log'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LogTransport&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'null'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NullTransport&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unknown messaging driver'&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;Four drivers, and the last two matter more than people expect. &lt;code&gt;log&lt;/code&gt; writes the message to your application log instead of sending it — that is your local development default. &lt;code&gt;null&lt;/code&gt; discards silently — that is your test suite default, so nothing escapes during CI.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Design rule:&lt;/strong&gt; the default in a fresh &lt;code&gt;.env.example&lt;/code&gt; should never be a driver that can reach a real phone. Make the safe option the one you get by forgetting to configure anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The notification channel
&lt;/h2&gt;

&lt;p&gt;Wrap the transport in a custom channel so notifications stay declarative:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Messaging&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Notifications\Notification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WhatsAppChannel&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;MessageTransport&lt;/span&gt; &lt;span class="nv"&gt;$transport&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$notifiable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Notification&lt;/span&gt; &lt;span class="nv"&gt;$notification&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;$to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$notifiable&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;routeNotificationFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'whatsapp'&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;$to&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="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$notification&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toWhatsApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$notifiable&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;transport&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&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;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$to&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;normalise&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;$number&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="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\D+/'&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;$number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// digits only&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;Call sites now look like this, and stay this way forever regardless of provider:&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;$booking&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BookingConfirmed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$booking&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The local gateway
&lt;/h2&gt;

&lt;p&gt;The gateway is a small Node service that owns a linked WhatsApp session and exposes a minimal HTTP surface. Three design decisions carry all the weight:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Bind to loopback only
&lt;/h3&gt;

&lt;p&gt;The gateway holds credentials equivalent to a logged-in phone. It must never be reachable from the internet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3010&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// not 0.0.0.0. ever.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel talks to it over &lt;code&gt;http://127.0.0.1:3010&lt;/code&gt;. If your app servers are separate machines, put it behind an authenticated internal tunnel — do not open the port.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Persist the session outside the process
&lt;/h3&gt;

&lt;p&gt;Pairing a device is a manual, interactive step. If your session state lives only in memory, every restart, deploy, or crash forces someone to re-scan a code — which means the service will be unavailable exactly when nobody is watching. Persist auth state to disk, and back that directory up like any other credential store.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Run it under a supervisor with restart limits
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/wa-gateway.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Local WhatsApp gateway&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;wagateway&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/wa-gateway&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/node server.js&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;300&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;NODE_ENV=production&lt;/span&gt;
&lt;span class="py"&gt;NoNewPrivileges&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;PrivateTmp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;ProtectSystem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;strict&lt;/span&gt;
&lt;span class="py"&gt;ReadWritePaths&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/wa-gateway/session&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StartLimitBurst&lt;/code&gt; matters. A gateway whose session has been invalidated will fail on every start; without a limit, systemd will restart it in a tight loop and hammer the upstream service, which is the fastest way to get a number flagged. Let it fail loudly after five attempts and alert on the unit state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transport implementation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Messaging&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LocalGatewayTransport&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;MessageTransport&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;$config&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;send&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;$to&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;$body&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="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;timeout&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;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'timeout'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;10&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;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;throw&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withToken&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;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'token'&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;post&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;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/send'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'to'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$body&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;failed&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;TransportException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'Gateway rejected message: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&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;''&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;Note the explicit timeout. A messaging gateway that hangs will hold a queue worker hostage; without a timeout the default can be far longer than you think, and a single unreachable service can drain your entire worker pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queue it, and rate-limit it
&lt;/h2&gt;

&lt;p&gt;Never send from inside a web request. Notifications should implement &lt;code&gt;ShouldQueue&lt;/code&gt; so a slow or dead gateway degrades into a delayed message rather than a timed-out HTTP response.&lt;/p&gt;

&lt;p&gt;More importantly, throttle deliberately. Messaging platforms treat sudden bursts from a new sender as abuse signals, and the penalty lands on your number:&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;// app/Notifications/BookingConfirmed.php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Contracts\Queue\ShouldQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Queue\Middleware\RateLimited&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BookingConfirmed&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Notification&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&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="c1"&gt;// seconds, escalating&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;middleware&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RateLimited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'whatsapp'&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;via&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$notifiable&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="nc"&gt;WhatsAppChannel&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'whatsapp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty per minute is a starting point, not a recommendation — tune it to your volume and warm a new sender up gradually rather than starting at full rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to log, and what never to log
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Log this&lt;/th&gt;
&lt;th&gt;Never log this&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provider message id&lt;/td&gt;
&lt;td&gt;Session tokens or auth state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recipient in masked form (+2010****9705)&lt;/td&gt;
&lt;td&gt;Full recipient numbers in plain text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Template or notification class name&lt;/td&gt;
&lt;td&gt;Message bodies containing OTPs or personal data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport latency and status code&lt;/td&gt;
&lt;td&gt;Anything you would not want in a support ticket&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Messaging logs are among the highest-sensitivity data your application produces, and they are frequently the least protected. Mask on write, not on read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without sending anything
&lt;/h2&gt;

&lt;p&gt;Because the transport is an interface, tests never touch the network:&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;test_customer_is_notified_on_confirmation&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="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$booking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&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="nv"&gt;$booking&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSentTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$booking&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;BookingConfirmed&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;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nc"&gt;WhatsAppChannel&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="nv"&gt;$channels&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;And force the safe driver in your test configuration so a misconfigured environment variable can never leak a real message from CI:&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="c"&gt;&amp;lt;!-- phpunit.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;env&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"MESSAGING_DRIVER"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"null"&lt;/span&gt; &lt;span class="na"&gt;force=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;force="true"&lt;/code&gt; attribute is the important part — without it, an existing environment variable wins and your test suite starts messaging real customers. We treat that flag as mandatory for every side-effecting service in &lt;code&gt;phpunit.xml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating to the official API later
&lt;/h2&gt;

&lt;p&gt;When the project graduates to the Cloud API, the work is contained:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Implement &lt;code&gt;CloudApiTransport&lt;/code&gt; against the same interface.&lt;/li&gt;
&lt;li&gt;Add template management, because the Cloud API requires pre-approved templates for business-initiated messages outside the customer service window. This is the one place the abstraction leaks — plain-text sends become template sends with parameters, so design &lt;code&gt;toWhatsApp()&lt;/code&gt; to return a structured object rather than a raw string if you know this migration is coming.&lt;/li&gt;
&lt;li&gt;Flip &lt;code&gt;MESSAGING_DRIVER&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Zero changes at any call site. That is the entire return on the indirection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put every messaging provider behind one narrow interface before you write the second call site.&lt;/li&gt;
&lt;li&gt;Default to a non-sending driver; make reaching a real phone something you opt into explicitly.&lt;/li&gt;
&lt;li&gt;A local gateway is a development and internal-notification tool. Customer-facing production messaging belongs on the official API.&lt;/li&gt;
&lt;li&gt;Bind the gateway to loopback, persist its session, supervise it with restart limits, and alert on the unit.&lt;/li&gt;
&lt;li&gt;Queue everything, rate-limit deliberately, time out aggressively, and mask recipients in logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We build and operate Laravel systems with WhatsApp, SMS, and email notification layers for businesses across Egypt and the Gulf. If you are designing this seam and want a review before it hardens into forty call sites, get in touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to use an unofficial WhatsApp library in production?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For customer-facing business messaging, no. Libraries that drive the WhatsApp Web protocol from a linked device fall outside WhatsApp's business messaging terms, and the number can be restricted or banned with no support path. They are reasonable for development, integration testing, and internal notifications to your own team's numbers with consent. Production customer messaging belongs on the official WhatsApp Business Cloud API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why route WhatsApp through Laravel's notification system instead of calling an API directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because it keeps the provider decision reversible. A notification declares what to send and through which channel; the transport underneath is bound at runtime from configuration. When pricing, terms, or provider availability change, you implement one new class and flip an environment variable instead of editing every call site in the application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop my test suite from sending real messages?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bind a null transport as the default and force it in phpunit.xml with force="true" on the environment variable. Without that attribute an existing environment variable takes precedence and a developer machine or CI runner with production values configured will send real messages during tests. Use Notification::fake() in feature tests so assertions run without any transport being invoked at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What rate should I send WhatsApp messages at?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lower than you think, and ramped gradually. Sudden bursts from a new sender look like abuse and the penalty lands on your number. Put a queue rate limiter in front of the channel, start conservatively, warm the sender up over days rather than launching at full volume, and make the limit a configuration value so you can lower it instantly during an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should a messaging gateway never do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It should never bind to a public interface, never log message bodies or unmasked recipient numbers, never run without a supervisor restart limit, and never be called synchronously from a web request. The first exposes credentials equivalent to a logged-in phone, the second creates a high-sensitivity data leak, the third produces restart loops that get numbers flagged, and the fourth lets a hung gateway exhaust your workers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://web-pioneer.com/en/blog/laravel-whatsapp-notification-gateway-architecture" rel="noopener noreferrer"&gt;web-pioneer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Laravel 500 That Logged Nothing: When Your Web PHP Runs as a Different User</title>
      <dc:creator>Web Pioneer</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:37:42 +0000</pubDate>
      <link>https://dev.to/webpioneer/the-laravel-500-that-logged-nothing-when-your-web-php-runs-as-a-different-user-50h8</link>
      <guid>https://dev.to/webpioneer/the-laravel-500-that-logged-nothing-when-your-web-php-runs-as-a-different-user-50h8</guid>
      <description>&lt;p&gt;A contact form on a production Laravel site stopped working. Every submission returned the same response:&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="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Server Error"&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;No stack trace. No Sentry event. And &lt;code&gt;storage/logs/laravel.log&lt;/code&gt; had not been written to in eleven days — its modification timestamp was frozen on the exact day the form broke.&lt;/p&gt;

&lt;p&gt;That frozen timestamp is the whole story, but it took a while to read it correctly. The instinct is to treat an empty log as "nothing happened." It is usually the opposite: something happened &lt;em&gt;to the logger&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that misleads you
&lt;/h2&gt;

&lt;p&gt;An empty log during an active incident has three plausible explanations, and engineers usually check them in the wrong order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The code path never ran.&lt;/strong&gt; Easy to disprove — the form returned a 500, so something executed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The log level filtered it out.&lt;/strong&gt; Also easy — an uncaught exception logs at &lt;code&gt;error&lt;/code&gt;, which no sane config filters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The logger itself failed.&lt;/strong&gt; Rarely checked, because logging feels like infrastructure that cannot fail.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It was the third one. And it failed in the most inconvenient way available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: two different users, one log file
&lt;/h2&gt;

&lt;p&gt;On this stack, PHP runs under two identities depending on how it is invoked:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Runs as&lt;/th&gt;
&lt;th&gt;Can write to a 664 file owned by the site user?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CLI (php artisan, cron, deploy scripts)&lt;/td&gt;
&lt;td&gt;the site's system user&lt;/td&gt;
&lt;td&gt;Yes — it is the owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web requests (LSAPI / PHP-FPM pool)&lt;/td&gt;
&lt;td&gt;nobody (uid 65534)&lt;/td&gt;
&lt;td&gt;No — not owner, not in group&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This split is common on shared and cPanel-style hosting, and it is invisible during development because everything on a laptop runs as one user. It stays invisible in production too — right up until a file that the web process must write is created by the CLI process.&lt;/p&gt;

&lt;p&gt;Which is exactly what happened. A maintenance task run from the shell recreated &lt;code&gt;laravel.log&lt;/code&gt; with mode &lt;code&gt;664&lt;/code&gt; and the site user as owner. From that moment, every web request that tried to log threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UnexpectedValueException: The stream or file ".../storage/logs/laravel.log"
could not be opened in append mode: Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why the exception was completely silent
&lt;/h2&gt;

&lt;p&gt;The controller looked roughly like this — and this shape is everywhere in real codebases:&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Contact form submitted'&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="nc"&gt;ContactJob&lt;/span&gt;&lt;span class="o"&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;$payload&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;\Throwable&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="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Contact form failed: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Server Error'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;500&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;Trace it with a dead logger:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;Log::info(...)&lt;/code&gt; throws &lt;code&gt;UnexpectedValueException&lt;/code&gt; — permission denied.&lt;/li&gt;
&lt;li&gt;Control jumps to the &lt;code&gt;catch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Log::error(...)&lt;/code&gt; throws the &lt;strong&gt;same&lt;/strong&gt; exception again, this time from inside the catch block.&lt;/li&gt;
&lt;li&gt;Nothing catches that one. It propagates to Laravel's handler, which also tries to log, which also fails.&lt;/li&gt;
&lt;li&gt;The client gets a bare 500 with no context anywhere.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The general lesson:&lt;/strong&gt; a catch block that logs is only as reliable as the logger. If logging is the thing that broke, your error handling amplifies the outage instead of reporting it — and it does so in a way that destroys the evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Diagnosing it when the app cannot tell you anything
&lt;/h2&gt;

&lt;p&gt;The usual reflex is &lt;code&gt;php artisan tinker&lt;/code&gt;. That failed here for an unrelated but instructive reason: the CLI PHP binary on this server did not have the Redis extension loaded, while the web SAPI did. Any attempt to boot and handle a request from the CLI blew up on the session/cache driver before reaching the real bug.&lt;/p&gt;

&lt;p&gt;Two environments, two extension sets, two user identities. The CLI simply could not reproduce a web request.&lt;/p&gt;

&lt;p&gt;What worked was to stop trying to simulate the web context and instead &lt;em&gt;run inside it&lt;/em&gt; — a temporary diagnostic script in the public directory that boots the kernel, handles the target route, and prints the exception object 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="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="c1"&gt;// public/diag-temp.php — DELETE IMMEDIATELY AFTER USE&lt;/span&gt;
&lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/../vendor/autoload.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;require_once&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/../bootstrap/app.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$kernel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Illuminate\Contracts\Http\Kernel&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="nv"&gt;$request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Http\Request&lt;/span&gt;&lt;span class="o"&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;'/send-contact'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&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="s1"&gt;'diag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'diag@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'diag'&lt;/span&gt;&lt;span class="p"&gt;,&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;$kernel&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;var_dump&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;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the thing the log never got to record&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit it with curl, read the real exception, delete the file. The permission error appeared on the first run.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Handle with care:&lt;/strong&gt; a script like this bypasses your normal routing and middleware entry point. Give it an unguessable filename, run it once, and remove it in the same session. Never leave one on a production host.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three changes, smallest first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Rotate the poisoned file and recreate it writable by everyone that needs it.&lt;/strong&gt; The old log had also grown to 280&amp;nbsp;MB, which is its own problem:&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="nb"&gt;mv &lt;/span&gt;storage/logs/laravel.log storage/logs/laravel.log.backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;touch &lt;/span&gt;storage/logs/laravel.log
&lt;span class="nb"&gt;chmod &lt;/span&gt;666 storage/logs/laravel.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Make Monolog create future files with the right mode.&lt;/strong&gt; Laravel's log channels accept a &lt;code&gt;permission&lt;/code&gt; key, and without it you inherit whatever umask happened to be in effect:&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;// config/logging.php&lt;/span&gt;
&lt;span class="s1"&gt;'single'&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;'driver'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'single'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'path'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'logs/laravel.log'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'level'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LOG_LEVEL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'debug'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'permission'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mo"&gt;0666&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// survives log rotation and CLI-created files&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;3. Stop the catch block from being able to throw.&lt;/strong&gt; If the logger is the failure, the handler must degrade rather than escalate:&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="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;\Throwable&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Contact form failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'exception'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$loggingFailure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;error_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Contact form failed: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// syslog fallback&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Server Error'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;500&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;error_log()&lt;/code&gt; writes to the SAPI's own error log, which is owned by the web server and therefore always writable by it. It is not elegant, but it is the one channel that survives when your application logger does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug, found only after logging worked
&lt;/h2&gt;

&lt;p&gt;With the log alive again, the very next submission produced a real stack trace — and a completely different bug that had been hiding behind the first one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ErrorException: Undefined array key "url"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A queued listener read &lt;code&gt;$data['url']&lt;/code&gt; from the payload. That field was optional and absent on some of the forms feeding the same endpoint. One &lt;code&gt;?? null&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;p&gt;This is the part worth internalising: &lt;strong&gt;a broken logger does not hide one bug, it hides all of them.&lt;/strong&gt; Every failure during those eleven days collapsed into the same anonymous 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two operational notes that cost extra time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Queue workers cache your code.&lt;/strong&gt; After editing a listener, the running worker keeps executing the old class until restarted. Kill it and let your supervisor or cron restart it, then re-run the failed jobs with &lt;code&gt;php artisan queue:retry all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything the web process writes needs web-process-writable permissions.&lt;/strong&gt; Logs, cache files, compiled views, uploaded media. If a deploy script or a shell session creates any of them, it must set ownership and mode explicitly, or the next web request inherits a file it cannot touch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A checklist worth stealing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What you want to see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who does web PHP run as?&lt;/td&gt;
&lt;td&gt;&amp;lt;?php echo get_current_user(), ' / ', posix_getuid(); via a temp file&lt;/td&gt;
&lt;td&gt;The same identity your writable paths expect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the log actually being written?&lt;/td&gt;
&lt;td&gt;ls -l --time-style=full-iso storage/logs/&lt;/td&gt;
&lt;td&gt;An mtime from minutes ago, not weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can the web user append?&lt;/td&gt;
&lt;td&gt;stat -c '%U %G %a' storage/logs/laravel.log&lt;/td&gt;
&lt;td&gt;Mode that includes the web process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does CLI match web?&lt;/td&gt;
&lt;td&gt;php -m vs a web phpinfo()&lt;/td&gt;
&lt;td&gt;Matching extension sets, or you cannot reproduce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the log unbounded?&lt;/td&gt;
&lt;td&gt;du -sh storage/logs/&lt;/td&gt;
&lt;td&gt;Rotation configured; not hundreds of MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Add the monitor you wish you had
&lt;/h2&gt;

&lt;p&gt;The cheapest possible detection for this class of failure is a scheduled check that the log file is still being touched. If your application logs anything at all on a normal day, an mtime older than a few hours is an incident:&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;# alert if laravel.log has not been written in 6 hours&lt;/span&gt;
find /path/to/storage/logs/laravel.log &lt;span class="nt"&gt;-mmin&lt;/span&gt; +360 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING: laravel.log stale"&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire that into whatever already pages you. Eleven days of silent failure is not a logging problem — it is a monitoring problem that a logging problem exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An empty log during an outage means the logger failed until proven otherwise.&lt;/li&gt;
&lt;li&gt;CLI PHP and web PHP are frequently different users with different extensions. Reproduce bugs in the context where they occur.&lt;/li&gt;
&lt;li&gt;Never let a &lt;code&gt;catch&lt;/code&gt; block throw. Wrap logging calls or fall back to &lt;code&gt;error_log()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;permission&lt;/code&gt; explicitly on file-based log channels.&lt;/li&gt;
&lt;li&gt;Monitor the freshness of your log file, not just its contents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We run this pattern across the Laravel applications we maintain, alongside a stale-log check on every host. If you are hardening a production Laravel deployment and want a second pair of eyes on the failure modes that never make it into your error tracker, our team is happy to help.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does Laravel return a generic 500 with no log entry at all?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Almost always because the logger itself cannot write. If Monolog cannot open the log file in append mode it throws, and if your catch block also calls Log::error() it throws a second time from inside the catch, producing an uncaught exception with no recorded trace. Check the modification time on storage/logs/laravel.log first — a frozen timestamp during an active incident points straight at file permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my web request behave differently from php artisan tinker?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Web PHP and CLI PHP are usually separate SAPIs. They can run as different operating-system users, load different extension sets, and read different php.ini files. On many cPanel and shared hosts the web process runs as nobody while CLI runs as the site's own user. That means CLI cannot always reproduce a web-only failure, and a file created by CLI may be unwritable by the web process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What permissions should storage/logs/laravel.log have?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The web process must be able to append to it. Where web PHP runs as a different user from the deploy user, that in practice means mode 666 on the file and 777 on the directory, or aligning ownership so the web user's group has write access. Also set the 'permission' key on the log channel in config/logging.php so Monolog recreates the file with the correct mode after rotation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I debug a Laravel 500 when logging is broken?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run inside the web context rather than trying to simulate it. Drop a temporary PHP file into your public directory that boots the kernel, handles the target request, and dumps $response-&amp;gt;exception, then hit it with curl and delete it immediately. Give it an unguessable name and never leave it on the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can I detect this failure before users report it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Monitor the freshness of the log file, not just its contents. A scheduled job that alerts when laravel.log has not been modified in several hours catches silent logger failures. Pair it with an external uptime check that submits a real form or hits a health endpoint, so a broken write path surfaces as an alert rather than as weeks of missing leads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://web-pioneer.com/en/blog/laravel-500-error-empty-log-php-user-permissions" rel="noopener noreferrer"&gt;web-pioneer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
