Web — Display Tier
demostatics-web is the user-facing Laravel 11 application: the forum, polls, profiles, moderation, editorial news, and the Reports and Database listing pages. It is also the platform’s only implemented server and its only identity authority — see the system map for how the other repositories relate to it.
What it is, and what it is not
Section titled “What it is, and what it is not”The repository README states the boundary in one sentence: “This repository does not gather or process anything.” It reads rows that a separate worker tier is supposed to produce, and it displays them.
That worker tier does not exist in code anywhere on the platform. Status: Not built. Everything on this page that describes reading worker output describes a reader with no writer behind it.
What the app does own outright is the community side — forum, polls, profiles and authentication, moderation, editorial news and the About Us landing pages. Those live in this app’s own local database, and they work today.
Two other clients exist. The mobile app is a real consumer of this app’s /api/v1. The desktop client is not — it targets a different, unimplemented contract and never talks to this server.
Capabilities
Section titled “Capabilities”| Area | What it is | Data source | Status |
|---|---|---|---|
| Forum | Posts, threaded comments, communities, search and sorting | This app’s database | Shipped |
| Polls | Create polls, vote, comment | This app’s database | Shipped |
| Profiles & auth | Register, verify email, log in, edit profile | This app’s database | Shipped |
| Moderation | Roles (admin / moderator / editor / technical staff), bans | This app’s database | Shipped |
| Editorial news | Editorial articles by category and subcategory | This app’s database | Shipped |
| About Us | Editorial “about” articles; the site’s landing page | This app’s database | Shipped |
| Reports | Filterable report listing, exportable to Excel / Word / JSON / CSV | This app’s DB + external world geo store for the filters | Page and export Shipped; the rows have no producer — Not built upstream |
| Database | Filterable listing of the latest measured values | This app’s DB + external world geo store for the filters | Page Shipped; the rows have no producer — Not built upstream |
The Reports and Database pages read the reports and database_items tables in this app’s own database. Nothing writes to those tables in production. In local development they are filled by database/seeders/ReportSeeder.php and database/seeders/DatabaseItemSeeder.php, which are invoked only by DemoSeeder — a plain php artisan migrate --seed does not create them.
Requirements
Section titled “Requirements”- PHP 8.3+ with
ext-curl,ext-dom,ext-libxmlandpdo_sqlite - Composer 2
- Node.js 18+ and npm
No database server is required for local development. DB_CONNECTION defaults to sqlite and the database file is created for you.
Key locked dependencies from demostatics-web/composer.json: laravel/framework ^11.31, laravel/sanctum ^4.0, spatie/laravel-permission ^6.13, phpoffice/phpspreadsheet ^5.9, and pestphp/pest ^3.6 for tests.
Local setup
Section titled “Local setup”composer installcp .env.example .envphp artisan key:generate
php artisan migrate --seed # creates database/database.sqlite, seeds roles, categories, communities, editors, questions
npm installnpm run build # compiles CSS/JS into public/build
php artisan serve # http://127.0.0.1:8000git clone && composer install does not copy .env, generate a key, or migrate — the key and the migration are in post-create-project-cmd and the .env copy is in post-root-package-install, both of which only run on composer create-project. Run the steps above explicitly.
The root URL redirects to /news/about-us. There is no separate home page.
Demo data
Section titled “Demo data”To browse every page with realistic content, including a filled-in Reports page and working geo filters:
php artisan db:seed --class=DemoSeederOn the sqlite driver DemoSeeder also calls php artisan demo:world --force, which builds a small database/world.sqlite so the region → subregion → country → state → city dropdowns populate. On any other driver it skips that and prints a warning; you have to point world.* at a real store yourself.
Seeded accounts, all with the password password:
| Role | |
|---|---|
admin@demostatics.test | admin |
editor@demostatics.test | editor |
member@demostatics.test | user |
Live rebuilds
Section titled “Live rebuilds”Instead of npm run build plus php artisan serve:
composer run devThat runs php artisan serve, php artisan queue:listen --tries=1 and npm run dev together under concurrently.
Getting a usable account
Section titled “Getting a usable account”Registration requires email verification, and local dev ships MAIL_MAILER=log, so the verification mail is written to a file nobody reads. Two ways through it.
Read the link out of the log: register, then open storage/logs/laravel.log and follow the verification URL.
Or verify by hand:
php artisan tinker>>> App\Models\User::where('email', 'you@example.com')->first()->markEmailAsVerified();There is no admin UI for role assignment. Roles are granted only through tinker:
php artisan tinker>>> App\Models\User::where('email', 'you@example.com')->first()->assignRole('admin');Valid roles, from the UserRole enum in demostatics-web/app/Models/UserRole.php: user, moderator, admin, technical_staff, editor. New registrations get user.
The world geo store
Section titled “The world geo store”The Database and Reports pages filter by region → subregion → country → state → city. That geography is the dr5hn countries-states-cities dataset, imported once into an external store.
The app reads it through schema-qualified table names on the same connection as the rest of the app. App\Models\Region sets protected $table = 'world.regions', App\Models\Country sets 'world.countries', and so on. There is no separate world entry in config/database.php — only a world_sqlite path setting.
Because a dotted table name means something different on each engine, the data lives in a different place per driver. The application code is identical everywhere; only the operator setup changes.
| Driver | world resolves to | You provision | Automatic? |
|---|---|---|---|
sqlite | an ATTACHed database file | drop the dump at database/world.sqlite | yes — the only auto case |
pgsql | a schema inside DB_DATABASE | CREATE SCHEMA world, load the dump, GRANT SELECT plus USAGE ON SCHEMA world | no |
sqlsrv | a schema inside DB_DATABASE | CREATE SCHEMA world, load the dataset, grant SELECT | no |
mysql / mariadb | a separate database named world on the same server | CREATE DATABASE world, load the MySQL dump, GRANT SELECT ON world.* | no |
On sqlite, App\Providers\AppServiceProvider runs ATTACH DATABASE ? AS world on connect, using the path from config('database.world_sqlite') (WORLD_SQLITE_PATH, default database/world.sqlite). It first checks PRAGMA database_list so a re-attach cannot fail.
On pgsql and sqlsrv the store must be a schema inside DB_DATABASE — a two-part world.regions name cannot reach a different database. On MySQL, watch for the well-known sample database also named world; it has different tables and will shadow yours.
Graceful degradation, and why health is the only signal
Section titled “Graceful degradation, and why health is the only signal”App\Support\GeoData wraps every geo read in a try/catch (QueryException) and returns an empty Collection on failure. With no store connected, pages render with empty dropdowns and nothing 500s.
That is deliberate, and it has a consequence: from the outside, “there are no regions” and “the geo store is down” look identical.
The demo store is a stub
Section titled “The demo store is a stub”php artisan demo:world builds a 21-row sample: 3 regions, 3 subregions, 4 countries, 5 states, 6 cities. Its tables carry id, name and a parent id and nothing else — no latitude, longitude, ISO codes or any other dr5hn column. Anything that needs coordinates will not find them there.
The file is generated, not committed: /database/world.sqlite is in .gitignore. Replace it with the full dr5hn dump for real data.
Index the parent-id columns
Section titled “Index the parent-id columns”The filter cascade queries each level by its parent id: subregions.region_id, countries.subregion_id, states.country_id, cities.state_id. Those columns must be indexed in whatever store you load, or picking a state scans the whole cities table (roughly 150k rows in the full dataset) on every dropdown change.
The official dr5hn dumps ship with these indexes. The generated world.sqlite from demo:world also creates them, verified in its sqlite_master. A hand-loaded schema is the case to check.
Configuration
Section titled “Configuration”Settings live in exactly two places, and you should not need to grep controllers for either.
.env holds per-machine environment settings — app key and URL, database credentials, WORLD_SQLITE_PATH, session, cache, queue and mail. .env.example is otherwise stock Laravel.
config/demostatics.php holds the application tunables.
| Setting | What it controls | .env override | Default |
|---|---|---|---|
per_page | Rows per page on every paginated list | RESULTS_PER_PAGE | 10 |
password.min | Minimum password length | PASSWORD_MIN_LENGTH | 12 |
password.mixed_case | Require upper and lower case | PASSWORD_MIXED_CASE | true |
password.numbers | Require a digit | PASSWORD_NUMBERS | true |
password.symbols | Require a symbol | PASSWORD_SYMBOLS | true |
limits.title | Max post/poll title length | MAX_TITLE_LENGTH | 255 |
limits.content | Max post/poll body length | MAX_CONTENT_LENGTH | 40000 |
limits.ban_reason | Max ban-reason length | MAX_BAN_REASON_LENGTH | 10000 |
presence_ttl | Seconds a user counts as online after their last request | PRESENCE_TTL_SECONDS | 300 |
export_limit | Max rows a single Reports export includes | EXPORT_LIMIT | 5000 |
Three things stay in code because they are structural rather than values to tune:
- Login and registration rate limits —
demostatics-web/routes/auth.php, asthrottle:5,1andthrottle:6,1middleware. The API equivalents are indemostatics-web/routes/api.php. - Roles — the
UserRoleenum indemostatics-web/app/Models/UserRole.php. - Content-creation rate limits — these do not exist. See the known gaps below.
After editing any config/*.php in production, run php artisan config:clear (or config:cache) for the change to take effect. .env changes are picked up without caching in local dev.
Project layout
Section titled “Project layout”app/ Console/Commands/ CreateAboutUs, DeleteUnverifiedUsers, SeedDemoWorld (demo:world) Enums/ JobConfig, JobDefinition, JobPriority, JobStatus (dead scaffolding) Http/Controllers/ forum, polls, reports, database, moderation, geo, profile Http/Controllers/Api/V1/ the JSON API surface, incl. HealthController Http/Middleware/ CheckIfBanned, EnsureNotBanned, EnsureApiEmailIsVerified, ResolveApiUser, TrackUserPresence Http/Resources/ JSON resources for /api/v1 Models/ Eloquent models; Region/Subregion/Country/State/City point at the schema-qualified world.* tables Policies/ CommentPolicy, PollPolicy, PostPolicy Providers/ AppServiceProvider (ATTACHes world.sqlite on sqlite) Support/ GeoData, ReportExporter, TokenAbilitydatabase/ migrations/ local schema (forum, polls, users, reports, database_items, bans, tasks) factories/ model factories used by tests and seeders seeders/ Category, Community, Editor, Question, Role, Report, DatabaseItem, Demoresources/views/ Blade templates (Tailwind + Alpine); about/, forum/, news/, polls/, profile/, moderation/, auth/, components/, layouts/routes/ web.php every browser-facing route api.php /api/v1 auth.php Breeze auth routes and their throttles console.php scheduled/console entriesThe frontend is server-rendered Blade with Tailwind CSS and a little Alpine.js. There is no SPA and no JavaScript framework build.
Testing
Section titled “Testing”php artisan testConfirmed by running the suite: 211 tests pass, 922 assertions, in about 2 seconds. The suite is Pest on top of PHPUnit, split into tests/Unit and tests/Feature, with API coverage under tests/Feature/Api and auth coverage under tests/Feature/Auth.
phpunit.xml pins DB_CONNECTION=sqlite with DB_DATABASE=:memory:, so no setup is needed. It also sets:
<env name="WORLD_SQLITE_PATH" value="/nonexistent/world.sqlite"/>That is deliberate. Geo is absent in every test run, so the degradation path in App\Support\GeoData is what the tests actually exercise. Nothing in the suite proves the populated-geo path works.
Known gaps
Section titled “Known gaps”Each of the following was checked against the source, not inherited from a document.
User reporting is a stub. ModerationController::report() in demostatics-web/app/Http/Controllers/ModerationController.php is an empty method whose entire body is a TODO comment (“Reports should be send to random moderator as an email”). The route POST /users/{id}/reports is registered in routes/web.php and returns nothing. Status: Not built.
Nothing records who placed a ban. App\Models\Ban::bannedBy() declares belongsTo(User::class, 'banned_by'), but the create_bans_table migration creates only id, user_id, reason and timestamps. There is no banned_by column, so calling that relation fails. Status: Partial — bans work, attribution does not.
The web Database page ignores its own search box. resources/views/database.blade.php submits an input named search, and DatabaseController::index() only reads the seven geo and category id filters. The term is discarded. ReportsController likewise never reads search; the Reports view has no search input at all. On /api/v1, ApiController::applySearch() is real and is called by the report, database-item, editorial, post and poll controllers — so the JSON API searches and the web pages do not.
Web content creation is unthrottled. The README claims a throttle:20,1 on post and comment routes in routes/web.php. There is no throttle middleware anywhere in that file — a grep for throttle across routes/ returns hits only in auth.php and api.php. Status: Not built.
The job enums are dead and partly invalid. app/Enums/JobDefinition.php declares four cases (GatheringWeb1, GatheringWeb2, AnalysisAI, Uncategorized) but its parameters() and priority() methods match against self::TranslationMachine, self::AnalysisTrend and self::ConversionAudio, which are not declared — those arms are unreachable and would not resolve. priority() also returns [] from its default arm despite a JobPriority return type. app/Enums/JobConfig.php is a class, not an enum, with a constructor-shaped method named JobConfig() that assigns to a local variable and discards it. Nothing in app/, routes/, database/ or tests/ references any of the four job enums. Status: Not built scaffolding.
The tasks table is orphaned worker-coordination schema. The create_tasks_table migration defines task_id, job_type, parameters, priority, estimated_time_sec, dependencies, deadline, creation_timestamp, retry_count, max_retries, status, result_location and isOpen. App\Models\Task has an empty body. Nothing writes to it and nothing reads it. It is the shape of a worker handshake with no worker on the other end.
StorePostRequest is unused. app/Http/Requests/StorePostRequest.php returns an empty rules() array and is referenced nowhere outside its own file.
For the request and response shapes this app serves, see the JSON API v1 reference. For where the missing producer was supposed to sit, see the worker tier and the data pipeline.