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 / ValetThe 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.
- 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 photospecies/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 usersSupportTicketAdminMail+emails/support-ticket-admin.blade.phpadmin notification emailGET /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 recordanimals:syncnow callsmedia:process-animalsafteranimals:mirror-media; JSON rewrite addsThumbnail_Urlfield (first media record's thumbnail) alongside existingPhoto_Urlswelcome.blade.php: non-LCP animal cards useThumbnail_Url ?? Photo_Urls[0]; LCP card always uses full-size originalanimals/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_urlidempotentlymedia.thumbnail_urlcolumn added;SpeciesController::format()servesthumbnail_url ?? urlso the species search index immediately uses 100px thumbnails once generatedconfig/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-biosqueuespecies: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
- 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