The Forge
The mod platform for Single Player Tarkov, where modders published their work and players found, downloaded, and talked about it.
I retired from the project in August 2026, so this page is a post-mortem of sorts. It walks through the features I built for the Forge, how it ran in production, and some of the things that I learned during its two years of development.
What it was
Single Player Tarkov (SPT) is a community project that turns Escape from Tarkov into an offline game you play on your own, keeping its full progression and adding support for mods.
The Forge was the official home for those mods. Modders published releases, declared what each one needed to run, and heard from the people using them. Players searched the catalogue, checked what worked with their version of SPT, and downloaded what they wanted. It replaced the community's older WoltLab-based Hub and brought across its users, mods, categories, comments, and reactions, with old Hub links redirecting to their new pages.
I created the Forge and led its development from the first commit in May 2024 until I retired from the project in August 2026. The code remains open source under the Mozilla Public License 2.0, in an archived repository.
By the numbers
Traffic over the Forge's last 295 days online.
- Data served
- 125 TB
- Page views
- 129M
- Most visitors in one day
- 294,629
- Threats stopped by Cloudflare
- 2.5M
* Partial month
86%
Show the monthly figures as a table
| Month | Requests | Data served | Page views |
|---|---|---|---|
| October 2025 (11 days) | 38,857,426 | 5.3 TB | 4,835,962 |
| November 2025 | 135,461,593 | 22.0 TB | 17,020,581 |
| December 2025 | 144,309,896 | 17.1 TB | 15,233,666 |
| January 2026 | 125,060,277 | 12.1 TB | 12,416,093 |
| February 2026 | 94,254,705 | 7.5 TB | 9,315,293 |
| March 2026 | 129,209,467 | 8.9 TB | 10,153,354 |
| April 2026 | 172,927,167 | 10.2 TB | 10,547,473 |
| May 2026 | 182,028,016 | 11.3 TB | 11,325,046 |
| June 2026 | 217,130,349 | 13.8 TB | 12,993,052 |
| July 2026 | 241,732,078 | 10.6 TB | 16,048,634 |
| August 2026 (11 days) | 77,056,666 | 5.9 TB | 8,947,993 |
| Total | 1,558,027,640 | 124.7 TB | 128,837,147 |
Under the hood
The stack, and how production was laid out.
- Laravel 13 on PHP 8.5, served by Laravel Octane running on FrankenPHP, which keeps the booted application in memory between requests.
-
Livewire 4 single-file components with
wire:navigatepage transitions, and Alpine.js in the browser. - Flux UI Pro components, Tailwind CSS 4, and Vite.
- Blaze, optimising Blade components for faster renders.
- PostgreSQL for data, with Valkey (a Redis-compatible store) for cache and queues, and Laravel Horizon running the workers.
- Laravel Reverb WebSockets for chat, presence, and live notifications.
- Meilisearch through Laravel Scout.
- Cloudflare in front for caching, rate limiting, and analytics, with R2 for file storage.
- Monitoring with Laravel Nightwatch and NightOwl.
- Pest 4 tests, including browser tests sharded across CI runners, with PHPStan at its strictest level, Rector, and Pint.
Production
Laravel Forge provisioned the servers and deployed every push to the main branch. Cloudflare sat in front of all public traffic. The application server handled the web work, while the database, cache, search, and file verification each ran on a machine of their own. Monitoring data went to a second PostgreSQL database, kept apart from the primary one.
Players and modders reached the Forge through Cloudflare, which proxied requests to the application server and served files from R2. The application server queried PostgreSQL, used Valkey for cache and queues, searched Meilisearch, and called outside services. The NightOwl agent on the application server wrote Nightwatch telemetry to a separate monitoring database. The verification worker pulled jobs from Valkey, downloaded files from mod file hosts to check them, and wrote results to PostgreSQL. Players downloaded mods from the file hosts after a redirect.
Hover over or tap a part of the diagram, or choose one here.
Players and modders
Players browsed, searched, and downloaded through the site, modders published through it, and community tools read the public API. All of it arrived over HTTPS and WebSockets through Cloudflare.
Cloudflare
Every public request passed through Cloudflare first. It cached public API responses at the edge, enforced rate limits ahead of the server's own, blocked 2.5 million threats in the Forge's last 295 days, and served assets and uploads from R2 object storage.
Application server
Octane on FrankenPHP served every page and API request, keeping the booted application in memory between requests. The same machine ran Reverb for WebSockets, the task scheduler, the NightOwl monitoring agent, and Horizon's three worker pools: up to 20 general workers, 10 for download link checks, and 3 for alt detection and analytics.
PostgreSQL
The primary database: mods, releases, users, comments, the resolved version and dependency tables, and the activity and audit log. It moved from MySQL to PostgreSQL 18 in the Forge's final weeks, with PgBouncer pooling connections.
Valkey
A Redis-compatible store, reached over TLS and split into separate databases for general use, cache, queues, and API usage counters. Horizon's workers and the verification worker both pulled their jobs from it.
Meilisearch
Four search indexes (mods, addons, mod lists, and users), kept in sync by Laravel Scout and rebuilt every night. The command palette queried all four in a single multi-search call.
Monitoring with Nightwatch and NightOwl
Laravel Nightwatch recorded requests, queries, jobs, exceptions, outgoing HTTP calls, scheduled tasks, and more. Instead of sending that telemetry to Laravel's hosted service, the NightOwl agent on the application server received it over a local socket, buffered it in SQLite, and wrote it in batches to a second PostgreSQL database. A scheduled command pruned old records every day.
Verification worker
Docker and a queue worker for the verification queue only. It pulled jobs from Valkey, downloaded each file from its host, checked it in a throwaway container with no network, and wrote the results to PostgreSQL.
Outside services
Discord for sign-in and release announcements, GitHub releases for syncing SPT versions, Cloudflare's GraphQL Analytics API for the live visitor count, and MaxMind's GeoLite2 database for visitor locations, refreshed nightly.
Mod file hosts
The Forge never stored mod files. Authors linked to their own hosts, downloads were counted and then redirected there, and the verification worker fetched each file to check it, with a header check twice a day to spot changes.
Features
The features that set the Forge apart from a plain file host, from publishing and verifying mods to finding, collecting, and talking about them, and the tools that kept it all running. I'll go over the features and outline how they worked.
Publishing
-
Mod pages
Every mod had a page with its description, version history, addons, and comments in separate tabs. Descriptions supported Markdown with tabbed sections, syntax-highlighted code, embedded videos, and styled callouts.
- Each tab was a lazy Livewire component that showed a skeleton first and fetched its data only once opened.
- Markdown ran through CommonMark with four custom extensions: tab sets, callouts, lazy-loaded images, and YouTube embeds.
- HTMLPurifier then cleaned the output, allowing only YouTube and Vimeo iframes.
-
Releases tied to SPT versions
Each release declared which SPT versions it supported as a semantic version range, so players could tell at a glance whether it would work with their install. Releases with no declared range were kept and labelled as legacy versions.
-
Ranges were parsed with Composer's semver
library, the same constraint syntax PHP
developers write in
composer.json. - A queued resolver expanded every range into a lookup table of concrete SPT versions.
- It wrote only the differences, in batches of 500.
- Listings filtered with a plain join instead of evaluating ranges on every request.
-
Ranges were parsed with Composer's semver
library, the same constraint syntax PHP
developers write in
-
Release-day publishing
When a new SPT version was announced but not yet out, authors could upload their updates early and pin them to it. Those releases went live the minute that SPT version did.
- Pinning set a flag on the link between the release and the unreleased SPT version.
- The release stayed hidden while any pinned version was still in the future.
- A job ran every minute, locked to a single server.
- It published each pinned release in one transaction once its SPT version's publish date had passed.
-
Fika compatibility
Fika is a separate mod that adds co-op multiplayer to SPT. Every release recorded whether it worked with Fika, and the mod listing could filter on it.
- Each release stored compatible, incompatible, or unknown in an enum column, defaulting to unknown.
- A mod counted as compatible if any published release was.
- The listing filter ran as a subquery backed by a composite index on mod, compatibility, and publish date.
-
Addons
Creators could publish addons that extend someone else's mod with new content or functionality. Each addon had its own releases, tied to a range of the parent mod's versions.
- Addons were their own model, not a kind of mod.
- Each addon release stored a semver range against the parent mod's versions.
- An observer resolved that range into a table of matching parent releases whenever the range changed or the addon was attached to or detached from a mod.
- Mod owners could switch addons off for their mod.
-
Guided submissions
Before uploading, authors read and confirmed the content guidelines, then a short guided step helped them decide whether their work was a standalone mod or an addon for an existing one.
- The flow was three Livewire pages in sequence: guidelines, path check, then the form.
- Releases for SPT 4.0 and later required the mod to have an embedded GUID, which was later verified using the verification service.
- Creating a mod required multi-factor authentication to be enabled on the user's account for additional security.
-
Authors, source code, and licences
Mods could credit up to ten co-authors, link up to four public source code repositories, and had to declare a licence. The content guidelines required source code to be available for review.
- Co-authors and source links lived in polymorphic tables shared by mods and addons.
- The co-author picker refused anyone in a block relationship with the owner.
-
Warning notices
Mods could carry notices for players: a profile warning when a mod made permanent changes to a save that removing it would not undo, and a cheat notice for mods with features that resemble multiplayer cheats.
- The profile warning was switched on per category, with seven categories enabled by default.
- A per-mod override turned it off for mods that didn't need it.
- The cheat notice was a flag set by the author that rendered a red banner linking to the anti-cheat policy.
-
AI and advertising disclosure
Authors flagged mods and addons that contained AI-generated content and described how AI was used. Administrators could lock the flag on, and players could filter AI content out of the listing. Mods that contained advertising carried a flag too.
- Setting the AI flag made a disclosure of up to 1,000 characters mandatory, rendered as sanitised Markdown.
- Locking forced the flag on, enforced in both the edit form and the mod policy.
- The listing filter had three modes (include, exclude, or only AI content), kept in the URL.
-
Discord announcements
New mods and addons were posted to the SPT Discord server, with a role mention that pinged players who wanted alerts. New releases of existing mods were posted as updates, and addons had a channel of their own.
- It posted through webhooks anything a logged-out visitor could see that hadn't been announced yet.
- Flags on each mod, addon, and release prevented repeats.
- Each post was an embed with the authors, category, supported SPT versions, tags, and thumbnail.
Downloads and verification
-
Dependencies
Authors listed the other mods a release needed, each with a version range. Release cards showed the exact versions those ranges resolved to, and new releases started with the previous release's dependencies filled in.
- Whenever a release was saved, an observer matched each range against every version of the required mod.
- The matches went into a resolution table, so pages read dependencies with a join.
- The API built the full recursive tree from that table, with cycle protection.
- It chose one version per mod that satisfied every constraint in the tree.
-
Guided downloads
Downloading a release first reminded players that files were hosted elsewhere and should be scanned, then listed the release's required dependencies so they could grab everything in one go.
- Every download went through a Forge route that logged the event and redirected to the author's file host.
- The route allowed five downloads a minute per person and mod.
- The download counter updated after the response was sent.
- The API handed out the same download URL, so downloads started from tools were counted too.
-
Automatic file verification
Every new upload for SPT 4.0 and later was checked automatically. The link had to serve a real archive that opened safely, the GUID (the unique ID every SPT mod declares) and version compiled into the mod's DLLs had to match what the author registered on the Forge, and client and server files had to sit in the folders SPT loads from.
- The verification worker downloaded the file and hashed it with SHA-256.
- The file was mounted read-only into a fresh Docker container with no network, every Linux capability dropped, 1 CPU, 3 GB of memory, and a 256-process limit.
- Inside, a .NET 10 verifier extracted the archive with SharpCompress, counting bytes as they were written.
- Extraction stopped on zip bombs or path traversal.
- The DLLs' metadata was read without ever loading them: BepInEx plugin attributes for client mods, and constructor IL for server mods.
-
Public check results
Verification results appeared on each release card, with every check explained in plain language, and authors could re-run verification after fixing a problem.
- Each run stored the file's hash, size, file tree, and every check's outcome as JSON.
- A version number on the set of checks flagged results produced under older rules.
- Authors could re-run verification up to five times an hour, and staff had no limit.
-
Change detection
Twice a day the Forge checked every download link and re-verified any file that looked different from its last check.
- At 06:00 and 18:00 UTC a sweep queued one job per published release, in batches of 500.
- Each job sent a single HEAD request and compared the ETag, Last-Modified, and Content-Length headers with the stored values.
- The whole catalogue could be checked without downloading a file.
-
Safe fetching
Download links were supplied by users, so every request the Forge made to them was screened first.
- Hostnames were resolved for both IPv4 and IPv6.
- The request was refused if any address fell in one of 24 blocked ranges, including private networks, loopback, link-local, cloud metadata, and carrier-grade NAT.
- The connection was pinned to the checked address, closing the DNS rebinding gap.
- Only ports 80 and 443 were allowed.
- Each of up to five redirects was checked again, and the size cap was enforced from the headers and again as bytes arrived.
-
VirusTotal reports
Releases could link to VirusTotal scan reports, so players could check a file before running it.
- Authors added the links, with optional labels, when creating or editing a release.
Finding mods
-
Homepage
The homepage showcased featured mods, the newest and most recently updated mods, and the latest comments across the site.
- Guests got cached lists of IDs, fresh for five minutes and served stale for up to fifteen while they refreshed in the background.
- Each list held twice as many IDs as the page showed, so hidden items could be backfilled.
- The six featured mods were drawn at random from the whole featured pool on every visit.
- Model observers cleared the cached sections whenever a mod, release, or comment changed.
-
Command palette search
⌘K or Ctrl+K opened a site-wide search that matched mods, addons, users, and mod lists as you typed.
- Typing sent a Livewire request after a 250 ms pause.
- One Meilisearch multi-search call queried all four indexes at once.
- Mod results were re-sorted by the newest SPT version they supported, then by relevance.
- Users who had blocked you were left out, and only publicly visible records were indexed.
-
Filters
The mod listing filtered by SPT version, category, Fika compatibility, and AI content, showed active filters as removable chips, and could sort by how many players had favourited a mod.
- Filters lived in the URL through Livewire's URL-bound properties, so any view could be bookmarked or shared.
- Filtering ran as SQL subqueries.
-
What's new
Badges in the navigation counted mods created or updated since a signed-in player last looked, each with a dedicated page.
- Two timestamps on the user record marked the last look at each page.
- Marking a page as read reset its timestamp and cleared the cached count.
-
Feeds and redirects
RSS feeds followed the mod listing's filters, sitemaps kept search engines current, and links to mods on the old Hub redirected to their new pages.
- Feeds and sitemaps came from hand-written controllers rather than packages.
- Sitemaps were built on request and cached for six hours, listing only the roughly 800 users who had published something.
- Hub redirects looked up the old file ID stored on each imported mod.
-
Installer page
A dedicated page and video guide walked new players through the SPT installer, the usual first step into the community.
Mod lists
-
Mod lists
Players could build lists of mods, such as a personal modpack, and set the SPT version a list targeted so every card showed compatibility for that version. Entries could be dragged into order and given notes.
- A reorder reassigned the existing position numbers and saved them in a single upsert.
- One windowed SQL query found each mod's exact match or closest earlier release for the whole list at once, rather than one query per card.
- Lists held up to 250 entries, each player could have 50 lists, and notes were capped at 280 characters.
-
Sharing and forking lists
Lists could be public, hidden behind a secret share link, or private, and anyone could fork a list to make their own copy. Every account had a private Favourites list behind the heart button.
- Hiding a list generated a random 32-character token, compared in constant time when the link was opened.
- Owners could regenerate the token to revoke old links.
- Forking copied the entries, notes, order, and thumbnail in one transaction.
- The fork was a private list that remembered its source.
-
Missing dependency fill
One click added any dependencies a list was missing, following the full dependency graph. Authors could opt their mods out of lists entirely.
- A breadth-first walk started from the mods already on the list and followed each one's resolved dependencies.
- Additions were checked against the 250-entry cap and written in one transaction.
- Opted-out mods were skipped and named in a notice.
Community
-
Comments
Threaded comments on mods, addons, lists, and profiles supported Markdown, YouTube embeds, and styled callouts, along with likes, pinned comments, and a full edit history.
- One polymorphic table served all four places.
- Each reply stored both its parent and its thread's root, so a whole thread loaded in one query.
- Moving a reply re-stamped its descendants with a single recursive SQL update.
- Every edit added a new version row instead of overwriting the old one.
-
A smarter comment box
The editor caught pasted log files and pointed people to SPT's code paste service instead, and warned before posting a "when will this be updated?" request.
- An Alpine component ran regular expressions over the text as it was typed.
- The patterns matched SPT's log line formats and the 24-character IDs found in profile data.
- A match disabled the submit button, and the server ran the same log check again as a validation rule.
- The update request warning matched common phrasings and pointed to the community rules without blocking the post.
-
Spam defences
Comments passed through a hidden honeypot field and Akismet spam checks, with flagged posts held in a staff review queue. Accounts needed a verified email address before interacting, and disposable email domains were blocked.
- The honeypot used a randomly named field and rejected forms submitted faster than a person could fill them in.
- Akismet checks ran as queued jobs after posting.
- Flagged comments were hidden from everyone except staff and their author.
- The disposable domain list was refreshed nightly from a public list on GitHub.
-
Profiles
Profiles had a Markdown about section, a wall for comments, an activity feed, followers and following, and the user's mods, addons, and lists. Staff role icons appeared beside names across the site.
- The wall reused the comment system, with the user as its subject.
- The activity feed read the same event log used for moderation.
- Private events were hidden from everyone but the owner and staff.
-
Notifications
A bell in the navigation bar and a full notification centre, updated live as notifications arrived, with email delivery and per-type preferences.
- Email was queued only when the user's preference for that type was on.
- Any change fired an empty event on the user's private Reverb channel, telling the bell and the centre to refresh.
- No notification content travelled over the socket.
-
Blocking
Blocking someone collapsed their comments, cleared their notifications, removed follows in both directions, and stopped them from messaging you.
- Blocked authors' comments rendered behind a "Show comment" toggle.
- A queued job deleted existing notifications that came from the blocked user.
- Policies checked for blocks before allowing messages, follows, replies, and co-author credits.
-
Accounts
Sign-in with Discord or email, two-factor authentication, control over other signed-in browsers, and automatic timezone detection so dates appeared in local time.
- Discord sign-in used Laravel Socialite and recorded whether the Discord account had multi-factor authentication.
- Timezones were detected in the browser with jstz, falling back to the location of recent activity.
-
Chat
Private conversations updated in real time, with typing indicators, online presence, email alerts for missed messages, and archiving.
- Messages were broadcast over Reverb on private channels for the conversation and for each person in it.
- Typing used a presence channel per conversation with a 1.5 second idle timeout.
- A site-wide presence channel tracked who was online.
- An alert email was queued five minutes after each message.
- It was sent only if the message was still unread, neither person had blocked the other, and no alert had gone out for that conversation in the past five minutes.
Moderation and administration
-
Report centre
Any mod, addon, comment, list, or user could be reported. Staff worked through reports from one queue and acted on them in place.
- Every moderator was notified when a report arrived.
- The queue refreshed every ten seconds, and moderators could claim a report so others knew it was taken.
- Actions such as banning a user or disabling a mod ran in a database transaction.
- The same transaction wrote the audit log entry and linked it to the report.
-
Staff roles
Moderators, senior moderators (who could also ban), and administrators each had their own permissions, and staff actions required a verified email address.
- Senior moderators could ban regular users and moderators.
- Administrators could ban anyone except other administrators.
-
Bans and audit log
Bans ran from one hour to permanent. Banned users were emailed the reason and saw a notice with the ban's end date when they visited. Every moderation action was recorded in an audit log.
- Audit entries went into the same event table as site activity, flagged as moderation actions and written immediately.
- Each entry held the moderator's IP, device, location, and a snapshot of the target.
-
Alt account detection
An administrator-only investigation tool ranked accounts likely to belong to the same person and showed the evidence behind each match.
- A queued job scored candidates out of 100.
- A shared IP was worth up to 45 points, less the more accounts shared it.
- IPs used by more than 20 accounts were ignored.
- A shared disposable email domain added 35 points.
- Activity timing on a shared IP added up to 20, scoring highest when one account picked up within five minutes of another.
- Matching device fingerprints added up to 12.
- Deleted accounts could still be matched through their leftover activity.
-
Admin dashboards
Visitor analytics, API usage analytics with per-endpoint response times, SPT version management synced from SPT's GitHub releases, and a console for file verification.
- Visitor statistics were computed by queued jobs and cached for six hours, never inside a web request.
- Visitor locations came from a MaxMind GeoLite2 database, updated nightly.
- API usage was counted in Valkey after each response had been sent.
- Per-minute hashes and a latency histogram gave average and estimated 95th percentile times.
- Counters were rolled up every minute and kept for a year as daily totals.
-
Live visitor count
The footer showed how many people were on the site and the most there had ever been at once.
- Every five minutes a queued job queried Cloudflare's GraphQL Analytics API.
- It asked for HTML page requests in a five-minute window ending three minutes earlier, grouped by client IP.
- The count of distinct addresses was cached for the footer.
- A new peak was written to the database only when it beat the stored record.
Public API
-
Public API
A free, read-only JSON API with no key or sign-up, covering mods, addons, versions, archive file trees, categories, and SPT versions. Reference docs with code samples in four languages were generated from the code, alongside OpenAPI and Postman files.
- Mod queries could filter on 18 fields, including an SPT version range.
- Queries supported sorting, sparse field sets, related includes, and full-text search through Meilisearch.
- File trees came from verification: the container returned the archive's file list, capped at 10,000 entries, and the API served the latest passing result.
-
Update and dependency endpoints
Tools could send a list of installed mods and an SPT version and get back what was out of date, or resolve the full dependency tree before installing anything.
- The update check sorted each mod into updates, blocked updates, up to date, or incompatible with that SPT version.
- It never offered a pre-release to someone on a stable version.
- It held back an update that would break another installed mod's dependency range.
- The dependency endpoint picked one version per mod that satisfied every constraint in the request, and flagged a conflict when none did.
-
Edge caching and rate limits
Public API responses were cached at Cloudflare's edge, and rate limits were enforced there too, with a limiter on the server as a fallback.
- Middleware marked successful guest GET responses as publicly cacheable for five minutes, or an hour for categories and SPT versions.
- Responses that set a cookie were never marked cacheable.
- The server's own limit was 300 requests a minute per IP, counted in Valkey.
- That limit sat above Cloudflare's threshold, so the edge would trip first.
Lessons learned
Ten things two years of building the Forge taught me, most of them the hard way.
-
Pick the tools and keep them
The Forge's admin panel had three lives: five weeks on Laravel Nova, nine months on Filament, and the rest of its life as custom Livewire pages. It wasn't the only thing I kept swapping out.
Monitoring ran through Pulse, Telescope, 22 days of Sentry, and Flare, which I replaced with Nightwatch in June 2025, put back the very next day, and finally dropped in April 2026 for Nightwatch and the NightOwl agent. The moderation modals moved to Flux UI and back to Jetstream within a single day in March 2025. Octane went from FrankenPHP to Swoole and was back on FrankenPHP ten weeks later, and Blaze was installed, pulled out two days later, and reinstated a month after that.
No single switch looked expensive on the day. Added up, each one cost a migration, a round of new configuration, and whatever history the old tool had collected, and I paid that far more often than the Forge's problems called for.
Next time I'd choose carefully once and stay put until a tool genuinely stood in the way.
-
Migrated data has a long tail
Importing the old Hub looked like a one-off job. I started the WoltLab importer in the project's second week and didn't delete it until September 2025, sixteen months later, by which point its mod data object alone ran past 1,100 lines.
Old data carries every shortcut the previous system ever allowed, and each one surfaced as its own bug: type mismatches, duplicate key violations, email addresses that differed only by case, non-Latin usernames that needed a fallback slug, and deadlocks while syncing follows. One day saw six import fixes land. Another fix, for registration dates that fell into daylight-saving gaps, was reverted the same day it shipped.
Even after the importer was gone, the Hub kept reaching forward. Imported passwords stored with the older
$2a$bcrypt prefix had to be rewritten when Laravel 12 began rejecting them.A data migration is a project in its own right, and it deserves to be planned like one.
-
Real data finds the slow paths
The visitor analytics page worked fine until its table passed seven million rows and its queries started hitting a 30-second timeout. It got a capped date range, a separate
COUNT DISTINCTquery, and a covering index, and eventually its stats moved into a queued job altogether.That story repeated across the Forge. Pages that felt instant against seeded data started to drag once real traffic and real table sizes arrived. I made lazy loading throw an error in development in September 2024, yet I was still fixing N+1 queries two years later in the API, the RSS feed, chat, and update checks.
Livewire components quietly carried a lot of state. One pass in July 2025 cut the mod action components' memory use by 42% and halved their response time, and the comment system was rebuilt so replies loaded only when asked for. Comment threads stopped walking up parent IDs in a loop and switched to recursive CTEs.
Hardly any of it was clever: eager loading, indexes, and moving heavy work off the request. The hard part was knowing where each fix was needed, and only real data could show me that.
-
Cache plain values, and clear only your own
Caching rescued plenty of slow pages, then brought a family of bugs of its own. The cache handed counts back as strings, so navigation badges, unread notification counts, and two different pagination totals each needed an integer cast added after the fact. Caching Eloquent models, collections, and
stdClassrows led to serialization errors until I switched analytics stats and computed properties to plain arrays and strings.The most dangerous bug was also the easiest to miss. The disposable email blocklist cleared its cache with
Cache::flush, which empties the entire store rather than just its own keys, and it took two attempts a month apart, first versioned keys and then tags, to fix it properly. Even aCache::hasfollowed by aCache::getin the download counter turned out to be a race.Whatever goes into a cache should be plain enough to come back out unchanged, and code should only ever clear what it put there itself.
-
Make queued work safe to run twice
In October 2025 the Forge posted its Discord announcements twice. It was the most visible sign of a problem that kept coming back: duplicate runs of the Discord job had to be fixed again in April 2026, and comment notifications repeated whenever a job was retried.
The causes were in the queue settings and in when jobs were dispatched.
retry_afterwas shorter than Horizon's worker timeouts, which let a second worker pick up a job the first was still running. Notifications were dispatched inside database transactions, so a worker could start before the data it needed had been committed.Fixing it took a notification log that deduplicated sends, explicit retry, timeout, and failure handling on every job, guards on every scheduled task, and dispatching only after commit.
Anything that sends a message or writes a record from a queue will eventually run more than once, so it has to be harmless when it does.
-
Treat every submitted link as hostile
Once the Forge fetched files from addresses that authors typed in, it was effectively a downloader for anyone with an account, and much of the security work in 2026 came from taking that seriously.
Download links were first validated in August 2025. In April 2026 I started rejecting links built on raw IP addresses, and resolving hostnames so anything pointing at a private network could be refused, to stop server-side request forgery.
The verification worker added another layer in July 2026. It ran the same checks on the archive download itself, put a timeout on DNS lookups, verified TLS certificates, and added an IPv6 blocklist. It measured size limits on the bytes actually written rather than what an archive claimed, rejected entries that tried to escape the extraction folder, and read archive types from their first bytes instead of their names. Then a fix followed for 7z files whose large dictionaries ran the container out of memory.
Every URL in a form field is a request from a stranger, and it deserves that much suspicion.
-
Keep bots out of the error log
A public site receives a steady stream of junk requests, and left alone they show up as server errors that bury the problems that actually matter.
Through 2026 I taught the Forge to turn that junk away at the door: URLs and form fields with malformed UTF-8, arrays where the API expected a single value, broadcast requests with invented socket IDs, and Livewire requests that tried to change locked properties. Responses that had been 500s became 400s, exceptions caused by bots and bad client input were filtered out of reporting, and Livewire's too-many-calls errors were suppressed.
The noise had a cost beyond attention, too. In January 2026 I had to cut Flare's sample rate just to stay under its monthly limit.
An error log is only useful when every entry in it is something worth fixing.
-
Test against the database you run
Two days in 2024, one in June and one in September, went to 21 workflow commits, about half of them just trying to get the test job to connect to MySQL. One of them is titled "Would you please just connect!".
Reaching a database at all was only the start, because the differences between engines stayed hidden until something forced a second one into the picture. In January 2026 I fixed polymorphic type names whose backslashes MySQL had stripped.
The move to PostgreSQL in July 2026 turned up a whole batch more: foreign keys with no index (MySQL adds one automatically, PostgreSQL does not), a notifications column that had to become
jsonb, prepared statements emulated for PgBouncer, queries written for a single driver, and broken SQLite migrations. In the final weeks I added a CI matrix that ran the suite on both PostgreSQL and SQLite.Running the tests on the engine production actually used, and on more than one, would have caught them much sooner.
-
Keep the safety nets on during big changes
On 4 March 2026 I landed 68 commits in a single cleanup pass and reverted five of them before the day was over. Big changes were exactly when static analysis and browser tests were most useful, and exactly when they were most tempting to switch off or rush past.
PHPStan climbed from level 5 in September 2024 to level 6 in January 2025, then was removed during the Livewire 4 upgrade in January 2026. It came back at its strictest level in April 2026, along with a batch of fixes.
The browser tests moved from Dusk to Pest 4 in August 2025 and needed steady care after that: unique test data, explicit waits before assertions, and six tests skipped for 17 days over a Flux combobox bug. Smoke tests for every page and architecture tests arrived in April 2026. Sharding the browser tests across CI runners was added, removed five days later, and brought back with time-balanced shards in June 2026.
Those checks never felt more inconvenient than in the middle of a big change, and they were never worth more.
-
When a feature keeps fighting, simplify it
The live visitor count went through six versions in eleven months: a Livewire counter, WebSocket broadcasts, a JavaScript and Echo client, a peak-only tracker, a count without WebSockets, and finally a five-minute query against Cloudflare's analytics. The last version was also the simplest.
Other features followed the same path. Comment notifications started out subscribing people to every new comment on any page they owned or had commented on. Two rounds of cuts later, in January 2026, they only went out when someone replied directly to your comment. The API gained OAuth through Passport in May 2026 and lost it, along with Sanctum tokens, three weeks later in favour of an open, read-only API behind caching and rate limits. Caching rendered Markdown in database columns was reverted the day it shipped, and the light theme, which had needed its own contrast and flash fixes, was removed in July 2026.
When a feature keeps fighting back, the answer is usually to make it do less.
Thank you
Building the Forge was extremely gratifying. It grew from a first commit in May 2024 into the place where the Single Player Tarkov community published, found, and talked about its mods.
Thank you to everyone who helped with its development and design. Thank you as well to everyone who used it, and to everyone who still does through its new fork (opens in new tab), maintained by its new owner (opens in new tab).
I wish the SPT community nothing but love.

