waterhole/core
Waterhole is an open-source Laravel package for building modern community forums. It provides a full discussion engine with posts, comments, reactions, moderation, member management, and a customizable interface that integrates cleanly into existing Laravel applications.
Waterhole is a complete forum application shipped as a Laravel package — you are not building a forum, you are hosting, configuring, and extending one. Treat waterhole/core as the application and your Laravel app as the integration shell.
composer require waterhole/corephp artisan waterhole:install — publishes config, migrations, and assets, runs migrations, and walks you through creating the first admin account.php artisan storage:link so uploads/avatars resolve.config/waterhole.php — feature toggles (gamification, reactions, tags, private discussions), route prefix/domain, uploads/disk, mail, search driver, SSO settings.routes/ and src/Http/ — this is the real public surface; read it before assuming an endpoint exists.resources/views/ and the Alpine components — the frontend is Blade + Alpine + Tailwind, not a JS SPA, so customization is mostly server-rendered.Waterhole\Extend namespace — the supported hook registry. This is where "how do I add X to the discussion page" is answered.User model and authenticate via Socialite/your IdP. Decide this before seeding content; changing it later means reconciling users and authored posts.vendor/waterhole/core, stop — you've taken a path that composer update will erase.Extension packages as the unit of customization. Every non-trivial change should be its own Composer package (local packages/ or extensions/ path repo) that registers with Waterhole's extend registry at boot. One extension per concern — "acme/waterhole-sso", "acme/waterhole-branding", "acme/waterhole-slack-notifications" — so you can disable or upgrade them independently.
// packages/acme/waterhole-branding/src/Extension.php
public function boot(): void
{
Extend::assets()->add('acme-branding', __DIR__.'/../dist/branding.css');
Extend::ui()->add('discussion.afterHeader', view('acme-branding::callout'));
}
Slot-based UI injection over view forking. The registry exposes named insertion points (page headers, post footers, sidebar, composer toolbar). Inject a Blade partial into the slot; only publish and override a full view when there is genuinely no slot for it. Slot injections survive upstream releases; forked views do not.
Publish narrowly, diff aggressively. vendor:publish --tag=waterhole-views dumps everything. Instead publish only the two or three views you actually need into resources/views/vendor/waterhole/, and keep a note of which upstream version each was copied from. On upgrade, diff those files against the new vendor copies — that diff is your upgrade risk.
Theming via CSS variables, not views. Waterhole's Tailwind layer is driven by CSS custom properties. A theme extension that ships a single CSS file overriding those properties gets you 90% of branding with near-zero upgrade cost. Reach for markup changes only for structural differences.
Use domain actions, not raw save(). Mutations go through the package's action/service classes so that events, denormalized counters (reply counts, last-activity), notifications, and the search index all stay consistent. If you write a bulk importer that touches models directly, you own re-indexing and counter recalculation afterward — plan for scout:import and any recalc commands in the same job.
Integrate by listening, not by patching. Subscribe to Waterhole's domain events (DiscussionStarted, PostCreated, UserRegistered, moderation events) from your host app to push to Slack, mirror into a CRM, award app-side perks, or feed analytics. Always implements ShouldQueue — a slow listener on PostCreated is a slow reply button.
SSO pattern. Register an OAuth provider via Socialite, map the IdP identity to the Waterhole user, and sync profile fields one-way from the IdP on each login. Keep the forum's username/email columns as a cache of the IdP, never the source of truth, or you'll get drift and duplicate accounts.
Permissions as data, not code. Define custom permissions in an extension's registration, assign them to groups in the admin panel, and check them through the policies/gates the package already consults. Hardcoding if ($user->isAdmin()) in an extension bypasses the whole group-permission model and confuses moderators.
Local workflow. Run a queue worker in dev (php artisan queue:work) — notifications, search indexing, and image processing are all queued, and a dead worker looks exactly like a broken feature. Use the package factories in tests with RefreshDatabase, and disable gamification/notifications in the test environment so feature tests don't fan out into mail assertions.
vendor/. It is the single most common way to lose a day. Every change belongs in an extension, a published view, or a config override.NOASSERTION. Read the actual LICENSE file before a commercial deployment; don't infer terms from the GitHub badge.vendor:publish --force for compiled assets is fine; doing it for views or config silently overwrites your customizations. Tag your publishes individually.waterhole.php values read env() at boot. php artisan config:cache freezes them — a changed .env with a cached config is the classic "why is search still using the old driver" bug. config:clear first when debugging anything config-shaped.boot() wrapped in a try/catch that logs and returns, so a typo yields a degraded forum rather than a 500 on every route.ALTER the package tables; never edit the package's migration files. Be aware that major upgrades can rename columns your migration depends on.QUEUE_CONNECTION explicitly; the sync driver makes everything feel fine until it doesn't.storage:link and the configured disk, and if you're on S3, set CORS for any direct-to-bucket flow. A wrong disk usually surfaces as broken image URLs, not an exception.php artisan about, Telescope, and APP_DEBUG. For extension issues, confirm registration order and look for duplicate slot names — two extensions hooking the same slot produce confusing doubled markup rather than an error.How can I help you explore Laravel packages today?