Skip to content

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.

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.

AreaWhat it isData sourceStatus
ForumPosts, threaded comments, communities, search and sortingThis app’s databaseShipped
PollsCreate polls, vote, commentThis app’s databaseShipped
Profiles & authRegister, verify email, log in, edit profileThis app’s databaseShipped
ModerationRoles (admin / moderator / editor / technical staff), bansThis app’s databaseShipped
Editorial newsEditorial articles by category and subcategoryThis app’s databaseShipped
About UsEditorial “about” articles; the site’s landing pageThis app’s databaseShipped
ReportsFilterable report listing, exportable to Excel / Word / JSON / CSVThis app’s DB + external world geo store for the filtersPage and export Shipped; the rows have no producer — Not built upstream
DatabaseFilterable listing of the latest measured valuesThis app’s DB + external world geo store for the filtersPage 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.

  • PHP 8.3+ with ext-curl, ext-dom, ext-libxml and pdo_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.

Terminal window
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed # creates database/database.sqlite, seeds roles, categories, communities, editors, questions
npm install
npm run build # compiles CSS/JS into public/build
php artisan serve # http://127.0.0.1:8000

git 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.

To browse every page with realistic content, including a filled-in Reports page and working geo filters:

Terminal window
php artisan db:seed --class=DemoSeeder

On 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:

EmailRole
admin@demostatics.testadmin
editor@demostatics.testeditor
member@demostatics.testuser

Instead of npm run build plus php artisan serve:

Terminal window
composer run dev

That runs php artisan serve, php artisan queue:listen --tries=1 and npm run dev together under concurrently.

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:

Terminal window
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:

Terminal window
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 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.

Driverworld resolves toYou provisionAutomatic?
sqlitean ATTACHed database filedrop the dump at database/world.sqliteyes — the only auto case
pgsqla schema inside DB_DATABASECREATE SCHEMA world, load the dump, GRANT SELECT plus USAGE ON SCHEMA worldno
sqlsrva schema inside DB_DATABASECREATE SCHEMA world, load the dataset, grant SELECTno
mysql / mariadba separate database named world on the same serverCREATE 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.

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.

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.

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.

SettingWhat it controls.env overrideDefault
per_pageRows per page on every paginated listRESULTS_PER_PAGE10
password.minMinimum password lengthPASSWORD_MIN_LENGTH12
password.mixed_caseRequire upper and lower casePASSWORD_MIXED_CASEtrue
password.numbersRequire a digitPASSWORD_NUMBERStrue
password.symbolsRequire a symbolPASSWORD_SYMBOLStrue
limits.titleMax post/poll title lengthMAX_TITLE_LENGTH255
limits.contentMax post/poll body lengthMAX_CONTENT_LENGTH40000
limits.ban_reasonMax ban-reason lengthMAX_BAN_REASON_LENGTH10000
presence_ttlSeconds a user counts as online after their last requestPRESENCE_TTL_SECONDS300
export_limitMax rows a single Reports export includesEXPORT_LIMIT5000

Three things stay in code because they are structural rather than values to tune:

  • Login and registration rate limitsdemostatics-web/routes/auth.php, as throttle:5,1 and throttle:6,1 middleware. The API equivalents are in demostatics-web/routes/api.php.
  • Roles — the UserRole enum in demostatics-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.

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, TokenAbility
database/
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, Demo
resources/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 entries

The frontend is server-rendered Blade with Tailwind CSS and a little Alpine.js. There is no SPA and no JavaScript framework build.

Terminal window
php artisan test

Confirmed 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.

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.