We are a boutique hobbyist reptile breeding operation owned and operated by the love of my life, Becky and myself. I do all the web/business development, some of the husbandry and all of the marketing. Becky does most of the husbandry, even though I have the most experience with reptiles, we have more than 60 years of combined experience keeping and breeding exotics. π
The primary marketplace for selling reptiles online is morphmarket.com. This site imports our inventory from MorphMarket and presents it on gemreptiles.com with our own branding and storefront experience. π
- Backend: Laravel 11, PHP 8.2+ π
- Frontend: Tailwind CSS 3, Alpine.js ποΈ
- Database: PostgreSQL π
- Cache: Redis (predis) β‘
- Mail: SendGrid π¬
- Assets: Vite β‘
The app has two data paths for animal listings:
-
π¦ JSON import (public storefront): An admin uploads a JSON export from MorphMarket via the dashboard. This file is saved to
storage/app/public/animals.jsonand drives the homepage, category pages, and category filtering. These routes are cached by file mtime (30 min TTL) and never hit the database. -
ποΈ Database-backed listings: The
/animalsand/classifiedsroutes serve Eloquent-backed records with full search, filtering, and pagination. Animals are synced from the JSON import into theanimalstable viaAnimalImportController.
Auth uses a simple is_admin flag on User. Only admins can create, edit, or delete animal records. The Seller model is a profile linked 1:1 to a User and is editable from the profile page. π
The dashboard import accepts the JSON export from MorphMarket. Key fields used by the importer:
| Field | Description |
|---|---|
Animal_Id* |
π Used as the slug |
Title* |
π·οΈ Pet name |
Category* |
π Species category (e.g. Ball Pythons, Corn Snakes) |
State |
π For Sale, Breeder, Sold, Not For Sale |
Enabled |
β
Active or inactive |
Visibility |
ποΈ Public or private |
Price |
π΅ Listing price |
Dob |
π Date of birth (supports n/j/Y, n/Y, Y formats) |
Sex |
βοΈβοΈ male / female |
Photo_Urls |
πΈ Space-separated image URLs |
Mm_Url** |
π Link back to the MorphMarket listing |
Desc |
π Description |
| Flag | Default (prod) | Default (non-prod) | Description |
|---|---|---|---|
FEATURE_CLASSIFIEDS |
false |
true |
π Enables the classifieds marketplace feature |
composer install
npm install
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan storage:link
npm run buildFor local development:
npm run dev # Vite dev server with HMR π₯
php artisan serve # or use Laravel Herd / Valet πThe source is open. Our content, logos, and UI designs are Β© All Rights Reserved 2024β2026. π¨
Everything except secrets belongs in git. Blobs go in S3 or similar β not in the database, not in git. ποΈ
- ποΈ Migration
add_performance_indexes: composite index onanimals(status, availability, created_at); composite onclassifieds(status, price, created_at); partial index onmedia(mediable_type, mediable_id) WHERE moderation_status = 'approved'; partial index onanimals(species_id) WHERE status = 'published'; index onspecies(higher_taxa) - π
AnimalInventoryService: extracts JSON load/sort/filter logic fromroutes/web.php; per-sort-variant mtime-keyed Redis cache; active-only filtering moved to PHP before view render - π£οΈ
routes/web.php: welcome, categories, and all 5 category routes now useAnimalInventoryService; cache keys normalized towelcome:{sort}:{mtime},categories:{mtime},categories:{view}:{mtime}; species eager-load selects only needed columns - πΌοΈ
Media::scopeApprovedThumbnail(): approved + featured-first + limit 1 for efficient single-thumbnail eager loads - π
AppServiceProvider: slow query listener logs queries >1s toquerieschannel in production only - π
config/logging.php:querieschannel added (storage/logs/queries.log, warning level)
- π Fixed Alpine/Turnstile race condition: replaced
@submit.prevent="submitWithTurnstile($el)"withonsubmit="return submitWithTurnstile(this)"on all forms β no Alpine dependency, no event ordering issues - π―
submitWithTurnstilenow targets the specific.cf-turnstileDOM element (not.cf-turnstileclass selector) forturnstile.reset()andturnstile.execute()β fixes multi-form pages - π
onTurnstileVerifiednow locates hidden input viaform.querySelector('[name="cf-turnstile-response"]')rather than a globalgetElementByIdβ correct in all multi-form scenarios - β Added
<x-turnstile />+onsubmithandler to:auth/login,auth/forgot-password,support/create(were unprotected) - β
Added server-side
verifyTurnstile()viaValidatesTurnstiletrait to 6 controllers:RegisteredUserController,AuthenticatedSessionController,PasswordResetLinkController,SupportTicketController,InquiryController,ClassifiedInquiryController(none had server-side verification before) - π§ͺ
phpunit.xml: overrideTURNSTILE_SECRET_KEYandTURNSTILE_SITE_KEYto empty so tests bypass Turnstile verification (auth tests don't test bot protection)
- π·οΈ Pulse dashboard rebranded to "Gem Monitor" with app logo; simplified header in
resources/views/vendor/pulse/components/pulse.blade.php - π
config/backup.php:mail.tofallback prevents null email error in CI - π
.env.example: email validation fix resolves CI failures
- β Added
is_featuredboolean tomediatable; one featured media per mediable entity - π Admin star-picker UI on Animal and Species show pages via Livewire v3
FeaturedMediaPickercomponent + Alpine.js - π Star is mutually exclusive per entity (radio-style): clicking sets featured, clears others for same mediable
- π©Ή
PATCH /dashboard/media/{media}/featureroute viaDashboardMediaModerationController::setFeatured - πΌοΈ Animal and Species index thumbnails now prefer featured media over first media
- π
featuredMedia()+featuredApprovedMedia()relationships added toHasMediatrait andSpeciesmodel
- π¦ Installed
spatie/laravel-backupv9 withspatie/db-dumper(PostgreSQL support) - β° DB-only backup every 6 hours; full backup daily at 02:00; cleanup daily at 01:00
- βοΈ All backups push to
private_s3disk β DO Spaces "privates" bucket underbackups/prefix - π Retention: all backups kept for 12 days (~48 DB backups), then 1/day until 30 days, then pruned
- π Backups only run in the
productionenvironment; gzip compression on DB dumps - π Added
BACKUP_ARCHIVE_PASSWORDandBACKUP_NOTIFICATION_EMAILto.env.example - π Note: manually create
backups/dbandbackups/laravelsubdirectories in DO Spaces if desired
- π¬
POST /email/inbound(email.inbound) handles SendGrid Inbound Parse webhooks - π Parses
from/to/subject/text/html+ SendGridenvelopeJSON; deduplicates byMessage-IDheader - π§΅ Threads messages by contact email + base subject (Re:/Fwd: stripped); reopens closed conversations on new mail
- π€ Forwards to all
is_adminusers via queuedForwardedInboundEmailMail(was hardcoded; now dynamic) - ποΈ Admin CRM at
/dashboard/conversations: list with status tabs (open/closed/spam/all), thread view, reply, status update, delete - π¬
ConversationReplyMailqueues reply to contact withRe: {subject}threading - π Fixed broken
VerifyCsrfToken::$except(was array-of-array with route name; changed to URI stringemail/inbound) - ποΈ
email_conversations+email_messagestables;EmailConversation/EmailMessagemodels
- βοΈ New support ticket users now also receive a password reset link so they can log in before clicking the verification link
- π§ Without a known password, users had no path to authenticate and complete email verification
- π£οΈ Routes
/sellersβ/profiles, namessellers.index/showβprofiles.index/show - π§ Nav label "Sellers" β "Profiles"
- π Profile section heading "Vendor Profile" β "Social Media & Profile"; description and save button updated
- β Onboarding checklist item "Set up your vendor profileβ¦" β "Add your social media links to your profile"
- π€ Sellers index/show: visible text and empty-state copy updated to "Profiles" / "breeder"
- π£ Both layouts: default
og:site_name,og:type,og:url,og:title,og:description,og:image+ Twitter card tags usingog-default.jpgfrom CDN - π
animals/show: overridesog:type=product,og:title,og:description,og:imagewith animal's first media photo - π¦
species/show: overridesog:title,og:description,og:imagewith first approved species media photo - ποΈ Any page can add
@section('og_image', ...)/@section('og_title', ...)/@section('og_description', ...)to customize
- ποΈ New
support_ticketstable (name, email, type, message, user_id FK nullable) - ποΈ
SupportTicketController: validates input, creates a newUserwith a secure random password when the email is unknown, firesRegisteredevent to send verification email, links existing users without re-registering, creates ticket, queuesSupportTicketAdminMailto all admin users - π§
SupportTicketAdminMail+emails/support-ticket-admin.blade.phpadmin notification email - π£οΈ
GET /support/POST /support(support.create / support.store) - π Support link added to site footer under Legal column
- π Species search index:
SpeciesController::index()pre-fetches page-1 results using same Redis cache key assearch(); results injected aswindow.__speciesInitial__; Alpineinit()consumes SSR data directly when in default state (no query/taxon/hasMedia/page=1), skipping the initial XHR entirely - π Both layouts:
<link rel="preconnect" href="https://gemx.sfo3.digitaloceanspaces.com" crossorigin>β browser opens TLS connection before first image request - πΌοΈ welcome.blade.php:
decoding="async"on all non-LCP lazy-loaded animal card images - π¦ Species thumbnails sized to 100Γ100px (up from 40Γ40px);
fetchpriority="high"on first result row image;loading="lazy"bound per-row via Alpine$index - ποΈ Axios removed from JS bundle (
bootstrap.js); all XHR uses nativefetch()β bundle 84KB β 45KB - π¨ CSS bundle: removed invalid
@tailwind forms;directive and redundant compiled-views glob from tailwind.config.js β bundle 81KB β 71KB - β‘ Async CSS loading in production via
rel="preload" as="style" onloadswap with<noscript>fallback; inline criticalbackground-colorprevents FOUC in guest layout - πΌοΈ welcome.blade.php: LCP image computed server-side,
<link rel="preload" as="image">injected in<head>, first card imagefetchpriority="high"(noloading="lazy"), rest lazy+decoding=async;width="800" height="800"on all card images (CLS) - βοΈ Removed
x-transitionfrom Alpine spinner and clear button (prevented forced reflow on layout-property reads) - π 301 redirects for all favicon/manifest paths via
routes/web.phploop;scripts/nginx-favicons.confadded for Forge nginx config to pass favicon paths through to PHP
- βοΈ
media:process-animalscommand: syncsanimals/prefix from DO Spaces, generates 400Γ400 square JPEG thumbnails via Intervention/Image, recompresses originals at Q85, syncs optimized originals andthumbs/animals/back to DO Spaces, updatesmedia.thumbnail_urlper record - π
animals:syncnow callsmedia:process-animalsafteranimals:mirror-media; JSON rewrite addsThumbnail_Urlfield (first media record's thumbnail) alongside existingPhoto_Urls - π
welcome.blade.php: non-LCP animal cards useThumbnail_Url ?? Photo_Urls[0]; LCP card always uses full-size original - π
animals/index.blade.php: cardimg srcusesthumbnail_url ?? url; LCP preload hint uses thumbnail
- βοΈ
media:process-speciescommand: syncsspecies/prefix from DO Spaces tostorage/app/spaces/, generates 100Γ100 square JPEG thumbnails via Intervention/Image (Imagick driver), recompresses JPEG originals at Q85, syncs optimized originals and newthumbs/species/prefix back to DO Spaces, and updatesmedia.thumbnail_urlidempotently - π
media.thumbnail_urlcolumn added;SpeciesController::format()servesthumbnail_url ?? urlso the species search index immediately uses 100px thumbnails once generated - πΌοΈ
config/image.phpcreated; Intervention/Image configured to use Imagick driver - ποΈ Command options:
--dry-run,--force,--no-sync,--skip-optimize,--batch=N
- π
welcome.blade.php:titleattributes on all sort/action/external links; richeralttext on animal images (name + category + sex + traits);loading="lazy"on animal images;rel="noopener noreferrer"on MorphMarket external links; invalid<h2 href>element corrected - π Favicon: both layouts now identical β removed redundant non-standard 100x100/192x192/256x256
rel="icon"links from guest layout (webmanifest covers Android/PWA sizes); addedmsapplication-TileColorto both layouts
- π Meta descriptions added to all public-facing routes: homepage, category pages, animals index/show, species index/show, subspecies show, sellers index/show, classifieds index/show
- π§©
@stack('meta')added to both layouts (app.blade.php,guest.blade.php) for per-page meta injection - π·οΈ Page titles updated to use
Gem Reptilesconsistently in both layouts; typo in guest layout title fixed - β‘ Bunny.net font loading changed from render-blocking
<link rel="stylesheet">to non-blockingrel="preload"+onloadswap with<noscript>fallback; eliminates font-induced paint delay - π¦ Species search thumbnails: added
width="40" height="40"(prevents CLS) andloading="lazy"(defers off-screen image fetches)
- π Cloudflare Turnstile rewritten: widget now uses
data-execution="execute"+data-appearance="interaction-only"β challenge runs on form submit (viasubmitWithTurnstile()), not on page load; eliminates Safari password-save dialog race condition that was invalidating tokens - π Turnstile component owns the
cf-turnstile-responsehidden input (data-response-field="false"); widget resets can no longer clear the token - π±
UserSeeder: 32 verified + 1 unverified non-admin users via FakersafeEmail - π CI
assetsjob: declareenvironment: Productionto unlock environment secrets/vars; usevars.ASSET_URL(notsecrets) - π Forge deploy: fetch
manifest.jsonfrom DO Spaces CDN viacurl(public-read, no-cache headers);optimize:clearbeforeoptimizeto nuke stale view cache - ποΈ Frontend assets build and CDN sync moved to CI
assetsjob (runs after tests pass on main); Forge no longer builds JS - π¦
manifest.jsonsynced withno-cacheheaders separately from hashed assets (immutable); deploy hook fires only after sync completes - β‘ Vite
baseonly applied inproductionmode; stalepublic/hotfile deleted
- π "Back to Dashboard" link moved into Pulse header via anonymous component override (
resources/views/vendor/pulse/components/pulse.blade.php); registered viaprependNamespaceinAppServiceProviderto override Pulse'sanonymousComponentPath - π Vite config fixed:
base(CDN asset URL) now only applied inproductionmode; dev mode no longer pollutespublic/hotwith CDN path, preventing stale hot file asset routing errors - πΈ Nav renamed "Photos" β "Media" (admin-only nav link)
- π§ Animal slug added to all inquiry email subjects (
AnimalInquiryMail,InquiryConfirmationMail,InquiryAdminNotificationMail)
- βοΈ Admin can edit all fields on species (
species,common_name,author,higher_taxa,species_number,changes,description) viadashboard/species/{id}/edit; same for subspecies (genus,species,subspecies,author,description) viadashboard/subspecies/{id}/edit - ποΈ Admin can detach photos from species/subspecies (hard-deletes DB row, file stays on S3) via hover-reveal Detach button on show pages and edit pages
- π‘ Authenticated (non-admin) users can submit description proposals on species and subspecies detail pages; submissions go to
species_content_submissionstable withpendingstatus - π Admin moderation queue at
dashboard/submissions: approve (writesproposed_valuetodescription) or reject; reviewer and timestamp recorded - ποΈ
media.deleted_atcolumn added (scaffolding for future soft-delete media library feature)
- π
species:import-checklistcommand imports data from the Reptile Database XLSX checklist: 1,288 new species, 219 new subspecies, 9 taxonomy change notes, 53type_speciesflag corrections; joins onsp_id/species_number;--dry-runand--taskoptions (species|changes|type_species|subspecies|all) - β©οΈ Species index back button now restores previous page number via
species_pagesessionStorage key - π Deploy hook: CI triggers POST to
DEPLOY_HOOKGitHub Secret after tests pass on main
- π€ Species biography generation:
species:generate-biosqueuesGenerateSpeciesBiographyJobper record; sources Wikipedia, iNaturalist, GBIF; outputs structured Markdown; skips existing bios unless--force;--limit,--model,--id,--dry-runoptions - βοΈ
species:work-biosqueue worker command for thespecies-biosqueue - π§Ή
species:normalize-bioscommand converts Wikipedia== heading ==markup to Markdown headings in stored descriptions;--dry-runand--modeloptions - ποΈ Wikipedia markup normalization applied directly to 7,084 existing species/subspecies rows via SQL
regexp_replace - β¨ Biography job now normalizes Wikipedia markup at generation time (headings, bold/italic, links, templates)
- π Bios rendered as Markdown HTML (
Str::markdown) in species and subspecies detail views, positioned below the photo gallery - π
species:sync-taxonomycross-checks all species against GBIF backbone; flags synonyms in thechangesfield (GBIF-SYNONYM:date:accepted_name); fills emptycommon_namefrom GBIF vernacular names; subspecies-safe (skips missing columns); options:--dry-run,--model,--limit,--min-confidence,--force,--genus,--family - π Taxonomy sync run: 68 species flagged as synonyms (notable: TrimeresurusβCraspedocephalus, LygosomaβRiopa/Subdoluseps, LobuliaβAlpinoscincus/Nubeoscincus); 480 common names filled
- π Species index pagination: links now appear both above and below the results table; results per page reduced from 100 to 80
- π
species:export-biosfixed:chunkByIdnow includesidin select; writes to local disk viafile_put_contents(default disk is S3) - π
database/sql/added to.gitignore(scratch SQL scripts)
- π Installed
keepsuit/laravel-opentelemetrypackage for OpenTelemetry integration, enabling tracing and metrics collection - π Installed
laravel/pulsepackage for real-time application performance monitoring and analytics dashboard, compatible with Alpine.js and Blade templates - βοΈ Configured Pulse with database migrations and published configuration/assets
- π Published OpenTelemetry configuration files for further customization
- π§ Added admin-only "Monitoring" navigation link in dashboard Quick Actions to access Laravel Pulse dashboard
- π Added "Back to Dashboard" link in Pulse monitoring page header for easy navigation back to admin dashboard
- π
species:fetch-imagessource chain expanded to 7 sources: added Reptile Database (HTML scrape, genus/species/subspecies URL params), ARMI USGS gallery (public domain government images), BioLib.cz (3-step HTML scrape, 2 s rate-limit between requests) - βοΈ
logs:uploadnow truncates each log file after successful S3 upload so subsequent runs start clean - π Species search: default alphabetical browse (100/page, paginated); text search returns flat results (no limit, cached 5 min); browse/taxa results cached 1 hour; Redis cache key prefix
species_search:; sessionStorage LRU result cache (species_rc_prefix, 20-entry max) - ποΈ Species search filter: "Has photos" checkbox + 6 mutually exclusive taxon pill buttons (Lizards, Snakes, Geckos, Turtles & Tortoises, Amphisbaenia, Crocodilians); taxon state persisted in
sessionStorage - πΌοΈ Species detail view: reactive attribution bar below gallery updates on thumbnail click (title, author, license, source link, "Full attribution" link)
- π Added media attribution page at
/media/{id}/attribution; linked from species/subspecies detail views - β Clear search button always visible; resets query, taxon filter, and sessionStorage state
- π€ Added
logs:uploadArtisan command β streamsstorage/logs/*.logtoprivate_s3disk; scheduled monthly on the 15th at midnight - π Added
private_s3filesystem disk (PRIVATE_S3_KEY,PRIVATE_S3_SECRET,PRIVATE_S3_BUCKET,PRIVATE_S3_REGION,PRIVATE_S3_ENDPOINT) β separate credentials from public media S3; visibility private - πΌ Added
FetchTaxonImageJobqueued job β dispatches one job per species/subspecies record on thespecies-imagesqueue;species:fetch-images --model=all --queueenqueues all unprocessed taxa - π
Scheduled
species:fetch-images --model=all --queueweekly, Sundays at 03:00America/Boise - π
species:fetch-imagesnow accepts--queueflag to dispatchFetchTaxonImageJobper record instead of processing inline;--maxoption controls images per taxon (default 1);buildQueryorders 0-image records first so re-runs always make forward progress; default--limitbumped to 1,000 - πͺͺ Image license filter relaxed: null/unspecified licenses accepted; only "all rights reserved" explicitly rejected
- β¬ οΈ Photo column moved to leftmost position in species search results table
- π Production database migrated from MySQL 8 to PostgreSQL
- π Added
species:fetch-imagesArtisan command β fetches free CC-licensed images for species and subspecies from a four-source chain: Wikipedia REST API β Wikimedia Commons direct file search β iNaturalist taxa API β GBIF species media API; resumable batched runs (--limit,--model,--id),--dry-runand--forceoptions, 500ms rate-limiting delay between requests - π€ Added
media:export-speciesβ exports approved species/subspecies media (with attribution) to a portable JSON file for production import - π₯ Added
media:import-speciesβ idempotent JSON import for production; matches records by scientific name (not ID) for cross-environment safety - π Added
source_urlandlicense_urlcolumns tomediatable; existinglicense,author,copyrightcolumns reused for full attribution storage - π¬ Scientific names now displayed (italic, linked to species record) on animal index cards, animal detail pages, and the homepage welcome cards
- πΌοΈ Species search results now show a thumbnail of the most recent approved photo instead of the type-specimen badge column
- πΎ Species search query now persists in
sessionStorage; random seed only fires on first visit or after the field is cleared - π Added
latestApprovedMedia()morphOne relationship toSpeciesmodel; used by the search API to return thumbnails in a single eager-loaded query
- π Applied patches to production server for kmod vulnerability
- π₯ Added
species:importArtisan command β imports Reptile Database CSV intospeciestable, supports--dry-runand--csv=options, deduplicates onspecies_number - π¦ Imported 11,440 species records from
reptile_checklist_2020_12.csv - π’ Added
SpeciesTypeenum (Syntype, Holotype, Lost, Paratype, Lectotype, Neotype) - π Added
SpeciesTypeCastβ parses space-delimited type tokens toSpeciesType[]; empty β"null" - ποΈ Updated
speciestable:type_speciesbool β varchar(10), unique index onspecies_number - π Added MeiliSearch full-text species search (
laravel/scout,meilisearch/meilisearch-php) - ποΈ Species search view with real-time Alpine.js UI, debounced input, dual-layer cache (client session + server 5-min TTL)
- π Species detail view (
/species/{id}) β taxonomy info card, approved photo gallery, user photo upload form - π Photo moderation pipeline: user uploads set
moderation_status = pending; admin dashboard reviews/approves/rejects - β Added
moderation_statuscolumn tomediatable (defaultapprovedβ existing media unaffected) - π Admin nav "Photos" badge shows count of pending species photos
- π€ Social auth buttons hidden on login and register views (pending re-enable)
- π Added
animals:backfill-speciesArtisan command β matchesAnimal.categoryto species viacommon_nameLIKE, supports--dry-runand--force-first; hardcoded overrides for Western Hognose (Heterodon nasicus) and Coastal Carpet Pythons (Morelia spilota) - π Added
species_idFK column toanimalstable (nullable,nullOnDelete) - π Added
AnimalβSpeciesbelongsTorelationship andSpeciesβAnimalhasMany - π Embedded Laravel 11 docs in
storage/docs/laravel/for local Claude inference reference - π€ Added Cloudflare Turnstile bot protection to animal and classified inquiry forms; server-side verification via
ValidatesTurnstiletrait; disabled automatically whenTURNSTILE_SITE_KEYnot set - βοΈ Species photo uploads use Digital Ocean Spaces (S3-compatible,
sfo3); admin uploads bypass moderation and publish immediately - βοΈ Configured
league/flysystem-aws-s3-v3S3 adapter;visibility: publicdefault ons3disk
- π Added social auth (Google, Facebook, Twitch) via Laravel Socialite
- π€ Added
SocialAccountmodel andsocial_accountsmigration
- π³οΈ Added
FEATURE_CLASSIFIEDSfeature flag (disabled on production by default) - ποΈ Removed HTMX and Hyperscript; Alpine.js only
- β¬οΈ Updated to Laravel 11
- π§Ή Removed UUIDs from models
- ποΈ Removed Dyrynda's deprecated UUID packages
- Updated package.json and composer.json dependencies
- Removed Daisy UI
- Added @tailwindcss/typography
- Updated root .gitignore