M. MAUN STUDIO
Let's Work Together
All work
Case Study8 min read

Case Study: BetterAddictionCare

Case Study: BetterAddictionCare

BetterAddictionCare.com — Engineering Case Study

Prepared: 2026-05-16 Engineer: Mau Codebase: addictiondirectory (Laravel 10 / PHP 8.1+, Filament admin, MySQL) Production domain: betteraddictioncare.com


1. The Product

BetterAddictionCare.com is a national directory for addiction treatment resources — rehab facilities, AA/NA meetings, editorial articles, and city/state microsites. It is not a generic Laravel app: the schema spans roughly 250 Eloquent models and ~163 migrations, with the Listing entity alone fanning out into ~100 normalized pivot tables covering accreditations, aftercare programs, amenities, clinical approaches, treatment modalities, and parallel staging tables for bulk imports.

The site serves the public through a single DynamicUrlController that resolves up to four URL segments at runtime against the State, City, Article, ArticleCategory, Author, LandingPage, and Listing tables. Heavy use of Cache::remember() keeps page latency tolerable despite the wide eager-loads required for treatment-listing pages.

Editorial and operations staff work entirely inside the Filament admin panel at /admin, which had grown into the de-facto CMS for listings, articles, microsites, suggested searches, FAQs, and call-tracking records.


2. Scope of Recent Work (last ~10 days, 60+ commits)

Three interlocking initiatives shipped in rapid succession on the upgrade-filament-v3 branch:

  1. Filament v2 → v3 admin panel upgrade (legacy / EOL modernization)
  2. Article ingestion pipeline — Google Docs Markdown import, Gutenberg/Laraberg/TipTap multi-editor support, featured-image automation
  3. Hybrid Meilisearch-powered search with polymorphic suggested results and a modal UX

Below is the story of each.


3. Initiative 1 — Filament v2 → v3 Admin Upgrade

Problem

The admin panel was pinned at filament/filament v2.17.56, which is end-of-life. No security patches, no first-party support for Laravel 10 long-term, and a growing ecosystem of plugins that had already moved to v3-only releases. Every new admin feature was building further on a dead branch.

Approach

Rather than a hand-migration of 537 files, the upgrade used Filament's official Rector ruleset as a first pass — automating namespace renames, method renames (modalButton()modalSubmitActionLabel(), modalSubheading()modalDescription()), and component swaps (CardSection, ViewFieldView). The remaining work was the long tail Rector couldn't reach:

  • config/filament.php was retired in v3 in favor of a Panel provider class. A new app/Providers/Filament/AdminPanelProvider.php was authored to hold the same configuration in fluent form.
  • The custom browser-side download mechanism in AppServiceProvider::boot — which dispatches a download-file window event from the server — had to be rewritten from Filament::registerRenderHook('scripts.end', ...) to FilamentView::registerRenderHook(PanelsRenderHook::SCRIPTS_AFTER, ...). This is what powers downloads from the Caching resource and other server actions.
  • The color API shifted from arrays to closures: ->colors([...]) became ->color(fn ($state) => ...) across five resource files.
  • TinyEditor (no v3 release) was dropped wholesale and replaced with the v3-native RichEditor across 7 resource files / 17 instances.
  • awcodes/filament-tiptap-editor was pinned to a v3-compatible version after a first attempt picked up a tag that silently required v2 internals.

Dependency cleanup

Two plugins were stranded by the upgrade:

  • z3d0x/filament-logger was bumped to ^0.8, then later removed entirely once an audit confirmed nothing in the codebase was actually consuming the activity-log resource it registered.
  • camya/filament-title-with-slug only ships v0.5.4, which still demands Filament ^2. The plugin was removed and the seven resources that used TitleWithSlugInput were migrated to native fields.
  • filament/spatie-laravel-media-library-plugin was unused and removed in the same pass.

Filament-adjacent fixes that fell out of the upgrade

  • SuggestedSearchResource needed its form/table schemas reshaped for v3's stricter type signatures.
  • The CallbackRequestTable Livewire component was rewritten from a v2-style array-returning method to the fluent table(Table $table) API.
  • Blade view tags x-filament::pagex-filament-panels::page; color="secondary"color="gray" to match v3's renamed palette.
  • Several class-name casing inconsistencies in Blade views surfaced because v3 autoloads more strictly on Linux deploys.

Risk surface

The upgrade churned ~1,479 insertions / 1,260 deletions in composer.lock alone. The branch is code-complete but flagged for smoke-testing — particularly the ListingResource (18 tabs, the most complex form in the panel), the ArticleResource (now backed by RichEditor), and the CachingResource (whose purge action exercises the rewritten render hook). The 13 resources that use Repeater->relationship() need verification that saves still cascade correctly under the new schema-bag semantics.

★ Insight ───────────────────────────────────── The most informative commit in this initiative is e19165593 Fix filament-tiptap-editor version for Filament v3 compatibility — it documents the version-constraint trap where a plugin tag lies about compatibility. In v3 ecosystems still maturing, composer require succeeding is not evidence that a plugin will boot. ─────────────────────────────────────────────────


4. Initiative 2 — Article Ingestion Pipeline

Problem

Editors author articles in Google Docs. Migrating each one into the admin meant copy-pasting body content, then re-uploading images one-by-one, then re-applying formatting that the rich editor mangled. Bulk operations were impossible.

What shipped

A new bulk-import path that accepts Google Docs Markdown export (replacing an earlier DOCX-based attempt that struggled with Google's HTML output). Key moves:

  • DOCX → Markdown pivot (7f8a402c3): Google Docs' Markdown export is far cleaner than its DOCX. The parser handles edge cases that broke the first version — escaped underscores inside identifiers, table syntax, and <sup> tags converted to bracketed reference markers [1] so editors can wire footnotes manually after import.
  • Featured-image automation (112839754, 7456c97253): Images referenced in the source document are downloaded from Google Drive, stored as regular files under /storage/, and registered against the article. An earlier iteration tried to push them through the Curator media library but it added complexity without paying back, so it was simplified to plain file storage (bdef08f4c).
  • Bulk Markdown import action (b9b8ee7db): exposed as a Filament bulk action on the articles list so an editor can drop a folder's worth of .md files and walk away. A subsequent fix (4b035c6c4) restored the medical_reviewer field that was being dropped during the bulk path.
  • Multi-editor support: articles can now be authored in Gutenberg (via Laraberg), TipTap, or the legacy RichEditor, distinguished by a new editor_type column. The detail view renders based on that column rather than guessing from which body field is populated (73d35c844) — a far more robust invariant than "whichever field is non-null wins."
  • Laraberg/Livewire sync ordering bug (e9c5db108): the Gutenberg editor wasn't flushing its DOM state into Livewire's component state until after commit, so saves silently dropped the latest edits. Fixed by syncing before commit. This is the kind of bug that only appears under fast-saving editors and is easy to miss in dev where humans pause between Ctrl-S and clicking.
  • Resilience hardening: multiple DOMDocument::loadHTML() call sites were guarded against empty content (013b7842e, 2e088c491) after the import flow produced articles with empty bodies that crashed the blog index. ShortcodeService was made tolerant of Gutenberg's particular quote encodings (ec13997fa) so legacy shortcodes embedded in imported posts still resolve.

Operational win

Admin users (role_id == 1) can now also preview draft articles (status=2) without publishing them (12088345d) — closing the loop on the import workflow: import → preview → publish.


5. Initiative 3 — Search Overhaul

Problem

The previous search was a simple SQL LIKE against listings, surfaced in a thin dropdown. It missed misspellings, didn't rank by relevance, and had no path to surface curated "we recommend you look at this" suggestions.

What shipped

  • Meilisearch integration (7d02f21c5) as the search backend, with a hybrid strategy that combines lexical match against indexed Listings, Articles, and now States (be2c50b99) — the latter so location queries hit a real entity instead of just full-text matches.
  • Polymorphic suggested searches (c487db31e): a curated SuggestedSearch model where each row points at any underlying model via a group → model routing map. Editors can pin "Top California Rehabs" or "Articles on Suboxone" and the search modal resolves them to the right destination URL at render time.
  • Modal UX (d08eafd3e, 4c5a9e5f9): the dropdown was replaced with a full-screen modal overlay that loads suggested searches grouped by category, with a refined trigger affordance.

6. Cross-cutting Polish

A handful of smaller commits in the same window are worth calling out because they show the kind of paper-cuts that accumulate around a large legacy app:

  • GeoIP removal cleanup (28114fa6f): a previous removal of the GeoIP package left orphaned function calls — found and removed.
  • PostCSS deploy fix (df63a0b09): syntax that worked locally broke on deploy. Fixed at the config level rather than working around it in CI.
  • FAQ schema (708c4e361) and meta-title + draft preview (691a6e3da): SEO surface area expanded for articles.
  • Pages model + media library (a8dde8ee4): groundwork for a more general content type beyond articles.

By the numbers: the article-import + search + Filament work together touched ~44 files with 2,205 insertions and 1,071 deletions in the most recent push window, on top of the dependency-graph churn from the Filament upgrade.


7. Architectural Lessons Reinforced

  1. The Listing schema is the rate-limiting factor. Any feature touching listings has to navigate ~100 pivot tables, several of which are near-duplicates kept for historical reasons. New attributes should always be added to an existing pivot if one already models the concept; the temptation to add a 101st is high and almost always wrong.
  2. DynamicUrlController is a load-bearing piece of glue. Its catch-all routing means any new top-level path must either be reserved in config/excludedSlug.php or routed explicitly before the dynamic catch-alls. The dual-domain routing (production betteraddictioncare.com + local 127.0.0.1) means new public routes need to be registered in both groups.
  3. Caching is editorial, not infrastructural. The Caching model and Filament resource are the canonical way to invalidate — php artisan cache:clear is not equivalent because the admin flow targets specific URL keys built from slug lookups.
  4. Plugins are the long pole on framework upgrades. Of the Filament work, the framework itself was largely Rector-automatable; what consumed time was orphaned/stranded community plugins. The lesson: a yearly audit of which Filament plugins are still maintained would have shortened this upgrade by days.

8. Status & Next Steps

  • Filament v3 branch: code-complete; smoke test pending. Open question: do Repeater->relationship() saves still cascade correctly across all 13 affected resources?
  • Article import: in production use. Watch for edge cases in tables and nested lists from Google Docs Markdown.
  • Search: live with Meilisearch; index freshness depends on a Scout queue worker that should be monitored after deploys.
  • Tests: the codebase still has near-zero domain coverage. Adding feature tests around the article import path is the highest-leverage place to start, because it now has a clear input (a .md file) and a clear output (an Article row + media files) — i.e., it's actually testable in a way most of the legacy controllers are not.

End of case study.