Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

106 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Street Bites

A full-stack Laravel application built with Livewire, real-time WebSockets via Reverb, Redis-backed queues and sessions, and Tailwind CSS. Developed as part of a YouTube series — the project follows a structured build from initial scaffolding through a complete feature set.

All development runs inside Docker containers managed by Lando. No local PHP, Composer, or Node installation is required.


Tech Stack

Layer Technology
Framework Laravel 13
Frontend Components Livewire 4
Client Interactivity Alpine.js
WebSockets Laravel Reverb 1
Build Tool Vite 8
CSS Tailwind CSS 4
Database MariaDB 10.11
Maps Leaflet + OpenStreetMap (keyless: tiles, static maps, Nominatim geocoding)
Cache / Queue / Sessions Redis 7
Runtime PHP 8.3
Node 20.x
Testing Pest 4
PHP Quality Pint · Larastan (PHPStan) · Rector
Asset Quality ESLint · Stylelint · Prettier
Dev Environment Lando (Docker)

Prerequisites

No local PHP, Composer, or Node needed — everything runs inside containers.


First-Time Setup

# 1. Clone the repo
git clone <repo-url> street_bites
cd street_bites

# 2. Start Lando (boots all containers)
lando start

# 3. Run the one-shot setup script
#    Installs PHP deps, copies .env, generates app key,
#    runs migrations, installs JS deps, and builds assets
lando composer setup

# 4. Link the public storage disk (needed for food-truck image uploads)
lando artisan storage:link

Running the Development Server

The app is served by nginx at https://street-bites.lndo.site as soon as Lando is up — there is no artisan serve step. Reverb also runs automatically as the reverb service (don't start it manually — see the Reverb note below). Run the remaining dev-time services individually, each in its own Lando command:

lando npm run dev      # Vite dev server with HMR (HTTPS on :5173 — see Vite note)
lando queue:work       # Redis queue worker

Reverb is already running — lando reverb:start would collide on port 8080 ("Address already in use"). Follow its output with lando logs -s reverb -f.

Do not use lando composer dev. The stock Laravel dev script runs bare vite (no --host) and artisan serve inside the appserver container, which writes an unreachable http://[::1]:5173 into public/hot and bypasses the nginx-served app. If assets ever 404 from a stale dev URL, delete public/hot to fall back to the built manifest in public/build.


Service URLs

Service URL
App https://street-bites.lndo.site
Vite Dev Server https://vite.street-bites.lndo.site:5173
Reverb WebSocket ws://localhost:8080
Mailpit (email UI) http://localhost:8025 (or https://mailpit.street-bites.lndo.site)

Lando Tooling Reference

lando artisan <cmd>    # Run any Artisan command
lando composer <cmd>   # Composer
lando npm <cmd>        # npm (Node 20 container)
lando pint             # Laravel Pint code style fixer
lando pest             # Pest test suite
lando logs -s reverb -f # Follow Reverb output (auto-runs as the reverb service)
lando queue:work       # Start Redis queue worker
lando mariadb          # Open a MariaDB shell
lando redis-cli        # Open a Redis shell

Running Tests

Tests are written with Pest (running on top of PHPUnit). The suite runs against a separate MariaDB database (street_bites_testing) on the same container, created automatically on lando start — no extra setup needed. DB-touching tests use RefreshDatabase (migrate fresh, roll back per test).

lando pest                       # run the full suite
lando pest --filter=StyleGuide   # run a subset
lando composer test              # artisan test (also routes through Pest)

Code Quality & Linting

A layered quality stack runs locally and on a pre-commit hook. See docs_and_archetecture/linting-and-code-quality.md for full detail.

Concern Tool Command
PHP style Laravel Pint lando pint (fix) · lando pint --test (check)
PHP static analysis Larastan / PHPStan (level 8) lando composer stan
PHP refactoring Rector lando composer rector:dry · lando composer rector
CSS lint Stylelint lando npm run lint:css (:fix to auto-fix)
JS lint ESLint lando npm run lint:js (:fix to auto-fix)
JS/JSON format Prettier lando npm run format · lando npm run format:check
All JS checks lando npm run lint
All PHP checks lando composer lint

Pre-commit hook

.githooks/pre-commit runs Pint, Larastan, ESLint, Stylelint, and Prettier on every commit — a failure aborts the commit. It is enabled automatically by lando composer setup (git config core.hooksPath .githooks). To enable it manually in an existing clone:

git config core.hooksPath .githooks

Bypass in an emergency with git commit --no-verify. Rector is not in the hook — run it on demand and review its diff before committing.


Project Structure

street_bites/
├── app/
│   ├── Actions/                # single-purpose actions (StoreTruckImage, GenerateTruckMapImage,
│   │                           #   ReverseGeocodeLabel, GeocodeSearch, ScreenText, ScreenImage)
│   ├── Http/
│   │   ├── Controllers/
│   │   └── Middleware/         # RequireCookieConsent (consent gate) + EnsureAdmin (moderation admins)
│   ├── Livewire/               # Livewire components (Auth/, Profile/, Admin/)
│   ├── Models/                 # User, FoodTruck, MenuItem, TruckImage, ModerationTerm, ...
│   ├── Jobs/                   # Queued jobs
│   └── Events/                 # Broadcast events
├── database/
│   ├── migrations/
│   ├── factories/
│   └── seeders/
├── public/
│   ├── favicon.svg             # vector icon (pin mark) + favicon.ico / apple-touch-icon.png
│   ├── robots.txt              # crawl rules + Sitemap: pointer to /sitemap.xml
│   └── images/                 # brand assets: street-bites-logo.svg (header logo),
│                               #   transparent logo/icon PNGs, og-image.jpg (social share card)
├── resources/
│   ├── css/                    # Tailwind 4 CSS-first design system
│   │   ├── app.css             # entry: @import 'tailwindcss' + partials
│   │   ├── theme.css           # @theme design tokens (source of truth)
│   │   ├── base.css            # global base + ADA accessibility rules
│   │   └── components/         # .btn, .food-truck-card, .mobile-nav, .mobile-header,
│   │                           #   .card-carousel, .filter-row, .profile, .truck-form, .toast, ...
│   ├── js/
│   │   ├── app.js              # imports the JS modules below (Alpine is bundled by Livewire 4)
│   │   ├── echo.js             # Laravel Echo / Reverb client config (not imported until a
│   │   │                       #   realtime feature ships — keeps pusher-js out of the bundle)
│   │   ├── truck-map.js        # Leaflet home-page map (lazy-loaded chunk) + closest-first card sorting
│   │   │                       #   + 100-mile radius cap + ZIP/address location-search fallback
│   │   ├── favorites.js        # favourite star toggle (optimistic Alpine + fetch)
│   │   └── search.js           # header search typeahead dropdown
│   └── views/
│       ├── components/         # anonymous Blade components (x-mobile-nav, x-mobile-header,
│       │                       #   x-food-truck-card, x-favorite-toggle, x-truck-discovery,
│       │                       #   x-card-carousel, x-truck-filters, x-truck-form, x-truck-map,
│       │                       #   x-location-search, x-social-links, x-toast, x-cookie-consent)
│       ├── layouts/            # app.blade.php (centered) + shell.blade.php (mobile chrome)
│       ├── livewire/           # full-page Livewire views (auth/, profile/)
│       ├── trucks/show.blade.php # public truck detail page (/trucks/{id}/{slug})
│       ├── welcome.blade.php   # home page — assembled mobile shell (/)
│       ├── favorites.blade.php # signed-in favorites page (/favorites)
│       ├── search.blade.php    # search landing page (/search?q=…)
│       ├── about.blade.php     # public "About us" page (/about)
│       └── styleguide.blade.php # living style guide (/styleguide)
├── routes/
│   ├── web.php
│   └── channels.php            # Reverb broadcast channel definitions
├── config/
│   ├── broadcasting.php        # Reverb configured as default broadcaster
│   ├── reverb.php
│   ├── admin.php               # ADMIN_EMAILS allowlist (moderation admins)
│   └── moderation.php          # text blocklist baseline + Rekognition image screening
├── tests/                      # Pest tests (Feature + Unit)
├── .github/workflows/          # CI + Terraform plan/apply + release-deploy (see Deployment)
├── docker/                     # nginx/php-fpm/supervisord config for the production image
├── terraform/                  # AWS infrastructure (bootstrap/, modules/, environments/prod/)
├── Dockerfile                  # production image (see Deployment)
├── .githooks/pre-commit        # quality gate (Pint, Larastan, ESLint, ...)
├── .stylelintrc.json           # Stylelint config (Tailwind-aware)
├── eslint.config.js            # ESLint flat config
├── .prettierrc.json            # Prettier config
├── phpstan.neon                # Larastan / PHPStan config (level 8)
├── rector.php                  # Rector config
├── .lando.yml                  # Lando (Docker) service definitions
└── vite.config.js

Front-End & Design System

Styling uses Tailwind CSS 4, configured CSS-first (no tailwind.config.js). The "Urban Vibrant" design system is encoded as Tailwind @theme tokens in resources/css/theme.css — each token generates both a CSS variable and utility classes from a single source of truth. A living style guide renders at /styleguide.

Branding is vector-first: the Street Bites logo (map pin + wordmark) ships as an SVG in public/images/ and renders in the app header, and every page links the favicon set (favicon.svg with .ico and apple-touch fallbacks) from public/. Every page also opens its <head> with the <x-seo-meta> component — title, meta description, canonical, and the Open Graph / Twitter tags that make shared links render as rich previews (backed by the 1200×630 share card public/images/og-image.jpg). Truck detail pages share with the truck's own name and description; auth pages, the styleguide, and search results are noindex. A dynamic /sitemap.xml (advertised by public/robots.txt) lists the home page, /about, and every published truck's detail page for search-engine crawlers — generated per request, so a newly published truck appears immediately.

Reusable UI is built as anonymous Blade components in resources/views/components/ (mobile header, bottom nav, food-truck card, the favourite star, card carousel, cuisine filters, the shared discovery section, the vendor truck form, a toast, and the cookie-consent banner), each pairing a CSS partial with a Blade template. The home page (/) assembles them into a mobile app shell. Client-side interactivity (e.g. the header's hamburger menu and the toast) is powered by Alpine.js, which Livewire 4 bundles and starts automatically — do not start a second Alpine instance in resources/js/app.js. Carousels and the filter row use native CSS scroll-snap rather than a JS slider library.

Vite compiles both CSS and JS:

lando npm run build   # production build (minified, hashed)
lando npm run dev     # dev server with HMR

The dev server serves HTTPS at https://vite.street-bites.lndo.site:5173 (directly on a published host port — the Lando proxy won't route Vite's custom port). HTTPS is required because the app is HTTPS and browsers block mixed content; the node service's ssl: true provides a CA-trusted cert at /certs that vite.config.js loads. This is all pre-wired — just run the command. If assets 404 from a stale URL, delete public/hot to fall back to public/build.

See docs_and_archetecture/frontend-framework.md for the front-end framework, and docs_and_archetecture/ui-component-architecture.md for the component library and its design decisions.


Cookie Consent (GDPR)

A custom consent banner appears on first visit. The app sets only essential cookies (session-backed sign-in and favorites — no analytics, ads, or tracking), so consent is deliberately all-or-nothing: accept, or decline and keep browsing anonymously without accounts/favorites.

  • No dark patterns — Accept and Decline are rendered with identical size, color, and font (one shared CSS class enforces the equal prominence GDPR requires), and nothing is pre-selected.
  • Easy withdrawal — a persistent round "cookie preferences" button stays on every page and reopens the banner; withdrawing consent while signed in also signs the user out.
  • Documented consent — every accept/decline is logged to a cookie_consents audit table (policy version, hashed IP, user agent, user id when signed in).
  • Enforced server-side — the RequireCookieConsent middleware gates the sign-in/sign-up flows and /profile; without consent those routes redirect home, where the banner reopens and explains.

The choice is stored in an encrypted cookie for ~6 months, after which the banner re-prompts. See CLAUDE.mdCookie consent (GDPR) for conventions.


Authentication

Three email-verified Livewire flows live under app/Livewire/Auth/:

  • Sign-up — enter email → confirm a 6-digit code (or click the emailed magic link) → set a password. The account is created only after the email is verified.
  • Sign-in — email + password (primary factor) → a one-time code emailed as a second factor → home. The bottom nav and hamburger menu show a Login link for guests and Profile once authenticated.
  • Password reset — the "Forgot password?" link on the sign-in form → enter email → confirm an emailed 6-digit code → set a new password and sign in. Reuses the same one-time-code engine; email ownership stands in for the lost password.
  • Sign out — a CSRF-protected POST /logout, reachable from the profile page and the hamburger menu, that tears down and regenerates the session.
  • Delete account — a confirmation-gated, CSRF-protected POST /account/delete in the profile footer that permanently removes the account and, via the food_trucks foreign-key cascade, every food truck the user owns (hours, menu, photos, social links, and favourite rows), then signs them out.

Security follows NIST SP 800-63B / OWASP guidance:

  • Argon2id password hashing (config/hashing.php), with legacy bcrypt hashes rehashed on next sign-in.
  • Password policy of min 12 characters plus a breach-database check (Have I Been Pwned) — favouring length over forced complexity.
  • Hardened session cookies (Secure, HttpOnly, SameSite, encrypted payload); the session id is regenerated on login.
  • Generic, rate-limited errors at each step to resist account enumeration and brute force.
  • Response-header hardening on every page (a SecurityHeaders middleware): HSTS, a Content-Security-Policy (report-only until enforced), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a Permissions-Policy that scopes geolocation to the site and denies camera/microphone/payment.
  • A /.well-known/security.txt (RFC 9116) telling researchers how to report a vulnerability.

The second factor is email OTP by design (not TOTP/SMS) to limit PII and complexity for now. See CLAUDE.mdAuthentication for the full convention set.


Profile & Vendor Management

/profile (the Profile link in the nav/menu once signed in) is the authenticated home for two roles in one page:

  • Eater by default — it shows the trucks a user has favourited. No one is assumed to be a vendor.
  • Vendor on demand — an "Add a food truck" button registers a food truck against the user. Owned trucks list as collapsible cards; expanding one lazy-loads an editable form for the truck's name, today's operating hours, menu items, photos, social-media links, and live location. Saving a new truck publishes it. Confirmations surface as toasts.
  • Social links — vendors paste their profile URLs (Facebook, Instagram, TikTok, X, YouTube, Snapchat…); the app detects each platform from the link and shows the matching brand icon on the truck's page. Unknown links get a generic globe.
  • Real-time presence — a Set my location button pins the truck at the vendor's current GPS position (and reverse-geocodes an area label); Now Open and Closing Up buttons stamp today's opening and closing time with one tap. All capture the browser timezone so times show in the truck's local zone (the app runs in UTC).

Data is persisted in a normalized schema (food_trucks, truck_operating_hours, menu_items, truck_images, truck_social_links, and a favorites pivot). Photo uploads are normalized to a 250×250 WebP (centre-cropped, EXIF stripped) via intervention/image and stored on the public disk — so a fresh environment needs lando artisan storage:link once (see setup). See CLAUDE.mdProfile & vendor management for the schema and conventions.


Discovery & Maps

Each published truck has a detail page (/trucks/{id}/{name-slug}) that discovery cards link to — its photos, cuisine tags, today's hours ("Open now — since …" when a vendor has flipped Now Open), location, menu, social-media icons, and a map of the surrounding area with a Get directions button that routes from the visitor's current location in Google Maps (opening the native maps app on a phone). Trucks that are serving right now carry a red "Now Open" badge on their card and are listed first — the home page leads with open trucks (alphabetical), and once location is shared the cards re-sort to the nearest open truck first. The home page also shows an interactive map of pinned trucks that centres on the visitor's location, with pins that filter alongside the cuisine pills and cluster into a count bubble where trucks gather (breweries, festivals, lots), expanding as you zoom in. Visitors who decline the GPS prompt get a fallback instead: a search card appears where they can enter a ZIP code or address, which is geocoded to rough coordinates — the map recenters and the cards re-sort just as if location had been shared.

Once a location is known (either way), every result surface applies a 100-mile radius cap: trucks further out are hidden from the discovery grids, the Popular carousel, the search results, and the map's pins, so visitors only ever see trucks they could realistically reach. The location is remembered for the browser session, so it keeps working across pages without re-prompting.

All mapping uses OpenStreetMap with no API key or billing: truck pages render a cached static map (dantsu/php-osm-static-api), the home page uses Leaflet, and both geocoding directions come from OSM Nominatim — reverse (pin → area label) and forward (typed ZIP/address → coordinates, proxied through a cached, rate-limited /api/geocode endpoint). See CLAUDE.mdMaps & geolocation.


Favorites & Search

Signed-in eaters can star any truck — on its discovery card, on its detail page, or from the favourites list on /profile. The star toggles instantly (optimistic UI backed by a POST /api/favorites/{truck} endpoint) and drives three things: the Popular near you carousel on the home page (the ten most-favourited trucks, open-now first), the filled stars across discovery, and the /favorites page — the home page's full discovery section (cuisine filters, map, distance-sorted cards) scoped to the trucks you've starred. Guests see no stars; favorites are part of the essential-cookie account features behind cookie consent.

The header search bar works on every page: type two or more characters and a dropdown suggests matching trucks — matched by truck name, cuisine tag (e.g. "Burgers"), or menu item name — each linking straight to its truck page, with a hint of why it matched when the name alone doesn't show it. Pressing Enter (or tapping the magnifier) lands on /search, which lists every matching truck as discovery cards, open-now trucks first. See CLAUDE.mdFavorites and Search.


Content Moderation

Adding a truck is intentionally frictionless — any signed-in user can publish one instantly. To keep that open door safe, new content is screened and there's an admin surface to act on anything offensive:

  • Auto-screening on publish — a truck's text (name, description, menu) is checked against a blocklist and its photos against AWS Rekognition image moderation (enabled in production). Clean trucks go live immediately; only flagged ones are held back for review, so honest vendors are never slowed down. Admins get an email the moment a truck is held.
  • Moderation queue (/admin/trucks, admins only) — an exception queue of only the trucks that need a human decision (auto-flagged/held and restored ones); clean trucks publish instantly and never appear here. Approve, Remove (a recoverable soft-delete that retains the content as evidence), and Block vendor actions. Removed trucks disappear from the whole site automatically.
  • Act from the truck page too — admins see a Remove this truck and Block this vendor panel directly on any truck's public detail page (invisible to everyone else), for taking down something offensive spotted live. Each is behind a two-click confirm so a mis-tap can't fire.
  • Community reporting — anyone (signed-in or not) can tap the megaphone in a truck page's footer to flag it as offensive. A report surfaces the truck on a Reported tab in the moderation queue for an admin to judge — it never takes the truck down on its own, so a single click (or a coordinated pile-on) can't be a censorship lever. Reports are deduped per reporter and rate-limited.
  • Editable blocklist — admins add or remove blocked words from the page itself; changes take effect immediately, no redeploy. A baseline list ships in configuration as an always-on floor.
  • Vendor blocks — blocking an offender stops them adding or publishing trucks and unpublishes their existing ones, but they can still sign in and browse: the block is on their vendor privileges, not their account.
  • Reinstatement requests — a blocked vendor can ask to be unblocked from their own profile; admins review the requests on a dedicated tab in the moderation queue and Reinstate or Dismiss each one. Reinstating lifts the ban only — the vendor re-adds/re-publishes their trucks (which re-runs screening).

Admins are defined by an ADMIN_EMAILS allowlist (no role table); image screening is off by default and turns on with MODERATION_REKOGNITION_ENABLED. See CLAUDE.mdContent moderation.


Environment & Configuration Notes

Database Credentials

DB_DATABASE=street_bites
DB_USERNAME=street_bites
DB_PASSWORD=street_bites

Reverb Host Split

Two separate env variables control Reverb — do not collapse them into one:

Variable Value Used by
REVERB_HOST reverb PHP on the server (Docker internal network)
VITE_REVERB_HOST localhost Browser (reaches Reverb on localhost:8080)

Docker service names are not resolvable from the browser, so these must stay separate.

The browser's localhost:8080 works because the reverb service in .lando.yml binds host port 8080 with an explicit ports: ['8080:8080'] mapping. Do not swap this for Lando's portforward: directive — for this custom service it assigns a random host port and silently breaks localhost:8080. Reverb itself runs automatically as that service's main process; never run lando reverb:start manually (it collides on 8080).

Internal Docker Hostnames

Services communicate by Docker service name, not localhost:

Service Internal Hostname External (Host Machine)
MariaDB database 127.0.0.1:3306
Redis cache 127.0.0.1:6379
Reverb reverb localhost:8080

Deployment (AWS)

Production runs on ECS Fargate — three services from one Docker image (web, reverb, queue-worker), RDS MariaDB, ElastiCache Redis, and S3 for truck images/map caches. Infrastructure is defined in terraform/ and rolled out via GitHub Actions (.github/workflows/): a PR quality gate, Terraform plan/apply on infra changes, and a release-triggered build-and-deploy pipeline. This is entirely separate from local dev (no Lando involved).

See terraform/bootstrap/README.md for one-time AWS/GitHub setup, terraform/environments/prod/README.md for provisioning the environment, and docs_and_archetecture/deployment-infrastructure.md for the full architecture writeup.


YouTube Series

This project is built live across a YouTube series. Each commit maps to a video episode — follow along to see every decision made from scratch.

https://www.youtube.com/playlist?list=PLCFAvrjCdis-mdDgzj3wAYA6wXjzgml9z

The app itself tells this story on its public About page (/about, linked from the menu) — the mission, the developer, and follow-along links to the YouTube series, the GitHub repo, and LinkedIn.

The app is free to use. When a BUYMEACOFFEE_URL is set (see config/external-links.php), an optional Buy Me a Coffee support link appears in three places — the About page, the profile page, and the header menu — and is hidden everywhere when that value is blank.

About

Street Bites repo. Laravel site with Livewire. Tailwind.css and Alpine.js. Terraform infra.

Topics

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages