awaisjameel / laravel-cpanel-hosting
A robust, secure, and production-ready Laravel package that makes deploying Laravel applications on **cPanel / shared hosting** painless and professional.
Package info
github.com/awaisjameel/laravel-cpanel-hosting
pkg:composer/awaisjameel/laravel-cpanel-hosting
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^9.0||^10.0||^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
A robust, secure, and production-ready Laravel package that makes deploying Laravel applications on cPanel / shared hosting painless and professional.
Shared hosting doesn't give you SSH-driven CI/CD, so this package exposes a small set of authenticated HTTP endpoints that let a webhook (GitHub, GitLab, Bitbucket, or your own script) drive a deployment: pull the latest code (outside the scope of this package), then hit /deploy to sync the environment, run migrations, rebuild caches, relink storage, and flip maintenance mode — all guarded by a token, an IP allowlist, and optional rate limiting.
Features
- Secure deploy endpoints — token (
X-Deploy-Tokenheader or?token=) or webhook signature (X-Hub-Signature-256/X-Gitlab-Token) authentication, optional IP allowlist, optional in-memory rate limiting. - Configurable deploy pipeline — a single
GET /deployruns an ordered list of steps (strings, artisan command arrays, or closures) and stops or continues on failure per your config. - Granular endpoints — every pipeline step is also its own route, so you can call
storage-linkormigrateon their own. .envsync — copies a server-side env file (e.g..env.server) over.env, with automatic timestamped backups and required-key validation (APP_KEYby default).- Storage link fallback — tries
symlink()first, and transparently falls back to a recursive directory copy (with correct file/directory permissions) on hosts wheresymlink()is disabled. - Installer command — publishes config, root
index.phppassthrough and hardened.htaccessstubs for cPanel'spublic_htmllayout, and (interactively) writes your deploy token/prefix straight into.env. - Dedicated deploy log channel — auto-registered if you haven't already defined one, so deploy activity doesn't get lost in
laravel.log. - Deploy lifecycle events —
DeployStarting,DeployStepCompleted,DeployCompletedfor hooking in notifications (Slack, email, etc.). - MySQL legacy compatibility — automatically applies
Schema::defaultStringLength()for older MySQL/MariaDB versions still common on shared hosting (utf8mb4+ short index key limits).
Requirements
- PHP 8.2+
- Laravel 11.x, 12.x, or 13.x
Installation
composer require awaisjameel/laravel-cpanel-hosting
Run the installer:
php artisan cpanel-hosting:install
This will:
- Publish
config/cpanel-hosting.php. - Install
index.phpand.htaccessat your project root (backing up any existing files first), so the app root can be pointed at your Laravel project directory directly instead ofpublic/on cPanel. - Append the package's env keys to
.env.exampleif they're missing. - When run interactively, prompt you to generate/set a deploy token, choose a route prefix, and optionally enable deploy routes immediately — writing the answers straight into
.env.
Installer options:
| Option | Effect |
|---|---|
--force |
Overwrite existing root index.php / .htaccess / config instead of skipping them. |
--only-config |
Publish only the config file, skip the root stubs. |
--only-root |
Install only the root stubs, skip publishing config. |
Non-interactively (e.g. in CI or a deploy script), the command skips the .env prompts and just publishes files:
php artisan cpanel-hosting:install --no-interaction
Configuration
Publish the config manually if you skipped it during install:
php artisan vendor:publish --tag="cpanel-hosting-config"
Every option reads from an environment variable so config/cpanel-hosting.php rarely needs to be touched directly:
# Core CPANEL_DEPLOY_ENABLED=false CPANEL_DEPLOY_TOKEN= CPANEL_DEPLOY_WEBHOOK_SECRET= CPANEL_DEPLOY_PREFIX=deploy CPANEL_DEPLOY_ALLOWED_IPS= CPANEL_DEPLOY_STOP_ON_FAILURE=true CPANEL_DEPLOY_LOG_CHANNEL=deploy # Rate limiting (applied per client IP) CPANEL_DEPLOY_RATE_LIMIT_ENABLED=false CPANEL_DEPLOY_RATE_LIMIT_MAX_ATTEMPTS=30 CPANEL_DEPLOY_RATE_LIMIT_DECAY_SECONDS=60 # Env sync CPANEL_SYNC_ENV_SOURCE=.env.server CPANEL_SYNC_ENV_TARGET=.env CPANEL_SYNC_ENV_BACKUP=true # Storage link CPANEL_STORAGE_LINK_PREFER_SYMLINK=true CPANEL_STORAGE_LINK_FALLBACK_COPY=true CPANEL_STORAGE_LINK_SOURCE=app/public CPANEL_STORAGE_LINK_PUBLIC_PATH=storage # MySQL legacy compatibility (utf8mb4 index-length limit) CPANEL_MYSQL_LEGACY_COMPAT=true CPANEL_MYSQL_LEGACY_LENGTH=191 CPANEL_MYSQL_LEGACY_ALL_CONNECTIONS=false # Maintenance mode secret bypass (Laravel's php artisan down --secret=) APP_MAINTENANCE_SECRET=
| Key | Default | Notes |
|---|---|---|
enabled |
false |
Deploy routes only register when this is true. Keep it false until you're ready. |
token |
null |
Shared secret compared with hash_equals(). Required unless you're using webhook signatures instead. |
webhook_secret |
null |
Enables X-Hub-Signature-256 (GitHub, HMAC-SHA256 over the raw body) and X-Gitlab-Token (direct compare) auth. |
route_prefix |
deploy |
Prefix all deploy routes live under. |
allowed_ips |
null |
Comma-separated string or array of IPs; when set, only listed IPs may reach deploy routes (checked before auth). |
rate_limit.* |
disabled | A lightweight in-memory (per-request-lifetime) limiter — see Security Notes for why this isn't a substitute for a real throttle. |
sync_env.source / target |
.env.server / .env |
Paths are resolved relative to the app base path unless absolute. |
sync_env.backup |
true |
Writes {target}.backup.{YmdHis} before overwriting. |
sync_env.required_keys |
['APP_KEY'] |
Sync fails if any of these keys are absent from the synced file. Edit the published config to add more (e.g. DB_PASSWORD). |
storage_link.prefer_symlink |
true |
Try symlink() first. |
storage_link.fallback_copy |
true |
If symlink() is unavailable or fails, recursively copy instead (with 0755/0644 permissions applied). |
storage_link.source / public_path |
app/public / storage |
Resolved via storage_path() / public_path() unless absolute. |
mysql_legacy_compat.enabled |
true |
Calls Schema::defaultStringLength() on boot when the active connection driver is mysql/mariadb. |
mysql_legacy_compat.all_connections |
false |
When true, checks all configured connections instead of just database.default. |
pipeline.default_steps |
see below | The ordered list of steps GET /deploy runs. |
pipeline.stop_on_failure |
true |
Stop the pipeline at the first failed step, or run all steps and report an overall failure. |
maintenance.secret |
null |
Passed as --secret to php artisan down, letting you bypass the maintenance page via /?secret=.... |
logging.channel |
deploy |
Auto-registered as a single driver writing to storage/logs/cpanel-deploy.log if you haven't defined this channel yourself. |
Endpoints
Once CPANEL_DEPLOY_ENABLED=true, routes are registered under CPANEL_DEPLOY_PREFIX (default deploy):
| Method & Path | Purpose |
|---|---|
GET /deploy |
Runs the full pipeline (pipeline.default_steps). |
GET /deploy/sync-env |
Copies sync_env.source over sync_env.target. |
GET /deploy/clear |
optimize:clear. |
GET /deploy/migrate |
migrate --force. |
GET /deploy/migrate-fresh |
migrate:fresh --force — destructive, drops all tables. |
GET /deploy/cache |
config:cache, route:cache, view:cache, event:cache. |
GET /deploy/queue-restart |
queue:restart. |
GET /deploy/storage-link |
Symlink (or copy-fallback) storage/app/public into public/storage. |
GET /deploy/maintenance-down |
down --retry=60, plus --secret if maintenance.secret is set. |
GET /deploy/maintenance-up |
up. |
GET /deploy/optimize |
optimize. |
GET /deploy/health |
Unauthenticated-payload health check (still requires deploy auth) — returns app_env, timestamp, and route prefix. |
Every endpoint returns a consistent JSON shape:
{
"success": true,
"message": "Deployment pipeline completed.",
"data": { "steps": [ { "step": "sync-env", "result": { "...": "..." } } ] },
"errors": []
}
Deploy routes deliberately bypass the session, CSRF, and default throttle middleware (see routes/deploy.php) since requests come from webhooks/CLI, not a browser session — auth is entirely handled by EnsureDeployTokenIsValid.
Authentication
Checked in this order by the deploy middleware:
- IP allowlist (
CPANEL_DEPLOY_ALLOWED_IPS) — if set, non-matching IPs get a403before auth is even checked. - Rate limit (if enabled) — exceeding it returns
429. - Webhook signature —
X-Hub-Signature-256: sha256=...(HMAC-SHA256 of the raw request body, GitHub-style) orX-Gitlab-Token: <secret>, compared withhash_equals(). - Deploy token —
X-Deploy-Token: {token}header (preferred) or?token={token}query string, compared withhash_equals().
If none of these pass, the route returns 403. If CPANEL_DEPLOY_ENABLED is false, every deploy route returns 404 rather than 403, so an unconfigured install doesn't leak the fact that the routes exist.
Customizing the pipeline
pipeline.default_steps accepts a mix of:
- Named steps (strings) —
sync-env,maintenance-down,optimize-clear,migrate,migrate-fresh,cache,queue-restart,storage-link,maintenance-up,optimize. - Arbitrary artisan commands —
'artisan:cache:clear'runsphp artisan cache:clear, or use an array to pass parameters:['command' => 'queue:work', 'parameters' => ['--once' => true]]. - Closures — for anything custom; must return
boolor a['success' => bool, 'message' => string, 'data' => array, 'errors' => array]shape.
// config/cpanel-hosting.php 'pipeline' => [ 'default_steps' => [ 'sync-env', 'maintenance-down', 'artisan:cache:clear', 'migrate', ['command' => 'db:seed', 'parameters' => ['--class' => 'ProductionSeeder', '--force' => true]], 'cache', 'storage-link', fn () => Http::post('https://hooks.slack.com/...', ['text' => 'Deploy finished!'])->successful(), 'maintenance-up', ], 'stop_on_failure' => true, ],
Events
Listen for these to wire up notifications or auditing:
use Awaisjameel\LaravelCpanelHosting\Events\{DeployStarting, DeployStepCompleted, DeployCompleted}; Event::listen(DeployStarting::class, function (DeployStarting $event) { // $event->steps, $event->ip }); Event::listen(DeployStepCompleted::class, function (DeployStepCompleted $event) { // $event->step, $event->result }); Event::listen(DeployCompleted::class, function (DeployCompleted $event) { // $event->success, $event->steps });
Facade
use Awaisjameel\LaravelCpanelHosting\Facades\LaravelCpanelHosting; LaravelCpanelHosting::isEnabled(); // bool LaravelCpanelHosting::routePrefix(); // string
Root hosting layout (cPanel public_html)
cPanel-style shared hosting typically serves everything under public_html/ directly, but Laravel expects the web root to be public/. The installer's root stubs solve this without a symlink:
index.php— a one-line passthrough (require __DIR__.'/public/index.php') so the app root is your project root..htaccess— blocks direct access to sensitive files (.env,composer.json/.lock,phpunit.xml,artisan) and internal framework directories (app,bootstrap,config,database,resources,routes,storage,tests,vendor), rewrites/public/...requests away, and serves everything else frompublic/— with baseline security headers (X-Frame-Options,X-Content-Type-Options,Referrer-Policy, a permissiveContent-Security-Policyyou should tighten per app).
Deploy your Laravel project as-is to public_html/ (or a subdirectory pointed at by your domain), run the installer once, and the app is servable without moving files around or fighting cPanel's document root.
Security Notes
- Keep
CPANEL_DEPLOY_TOKENsecret and rotate it immediately if it's ever exposed (logs, error trackers, a public repo). - Prefer the header token (
X-Deploy-Token) over the query-string token — query strings tend to end up in access logs and browser history. - Restrict with
CPANEL_DEPLOY_ALLOWED_IPSwhenever your CI/webhook provider publishes a stable IP range. - Never expose deploy routes with
APP_DEBUG=truein production — a failed step's stack trace should not be visible to the public internet. migrate-freshis destructive — it drops every table. Only include it in your pipeline if you're certain you want that behavior on every deploy (most apps shouldn't).- The built-in rate limiter is process-local, in-memory state (a static array), not a shared cache-backed limiter — it resets on every new PHP-FPM/CLI process and offers no protection across concurrent requests or multiple app servers. Treat it as a minor speed bump, not a defense against brute force; for real protection, pair the deploy token with
CPANEL_DEPLOY_ALLOWED_IPSor a firewall rule at the host level. - Webhook signatures beat static tokens where the provider supports them (GitHub/GitLab) — the payload is authenticated, not just a shared secret in a header.
Testing
composer test
Runs the Pest suite under tests/ (feature tests for deploy routes/middleware, unit tests for SyncEnvAction and StorageLinkAction) via Orchestra Testbench. Also available:
composer analyse # Larastan / PHPStan composer format # Laravel Pint composer test-coverage # Pest with coverage
Changelog
See CHANGELOG.md for recent changes.
Contributing
Issues and pull requests are welcome at github.com/awaisjameel/laravel-cpanel-hosting.
License
The MIT License (MIT). See LICENSE for more information.