I was working on a client project and came across a few things in the deployment that needed attention. The entire Laravel application was sitting inside public_html. It worked, but it also put the whole project directory inside the domain's public web root.
And that's a pretty big security problem.
In this case, it was possible to download both database/database.sqlite and storage/logs/laravel.log over the internet. Files like .env.example, artisan, composer.json, and composer.lock were accessible too, and an info.php page exposed server information.
The structure looked roughly like this:
public_html/ ← domain's public web root
├── app/
├── bootstrap/
├── config/
├── database/
│ └── database.sqlite ← you could download this
├── resources/
├── routes/
├── storage/
│ └── logs/laravel.log ← this too
├── vendor/
├── wp/ ← WordPress installation
├── sideproject1/ ← another entire Laravel application
├── public/ ← this should be Laravel's public web root
├── .env ← relied on an .htaccess rule for protection
├── .env.example
├── artisan
├── composer.json
├── composer.lock
└── info.php
You can go around blocking files one by one in .htaccess, but the main fix is to get those files out of the public area. Laravel already has a directory for handling browser requests: public/.
Preparing the application
The process is still a bit old-school, especially on shared hosting. Ideally, this whole thing would be automated through a pipeline, but until that exists, we can at least get the manual deployment in order.
First, build the assets locally or in CI. For this project, the command was:
npm ci
npm run production
This part depends on the project and its build tool. Use the script defined in your package.json.
We also need a clean vendor directory with just the production dependencies:
composer install --no-dev --optimize-autoloader
You can keep a local copy of the project just to prepare that vendor directory before uploading it. The environment used to install dependencies needs to be compatible with the hosting server's PHP version and extensions.
Check these settings in your production .env:
APP_ENV=production
APP_DEBUG=false
Separating the application from its public files
The idea is to keep the application in a directory outside public_html:
/home/user/
├── project/
│ ├── app/
│ ├── bootstrap/
│ ├── config/
│ ├── database/
│ ├── resources/
│ ├── routes/
│ ├── storage/
│ ├── vendor/
│ ├── .env
│ ├── artisan
│ ├── composer.json
│ └── public/
└── public_html -> /home/user/project/public
If your host lets you configure the domain's web root, you can point it directly at /home/user/project/public. Here, the solution was to point public_html at that directory with a symbolic link. This depends on the host allowing the link and the web server being able to follow it.
Before switching things around, check what's been left sitting in the project root. This project had quite a few things that still needed to be accessible through the browser:
robots.txt
sitemap.xml
cookie-policy.pdf
favicon.ico
css/
js/
images/
Those files go into public/. Meanwhile, .env, app/, config/, database/, storage/, vendor/, and the other internal files stay outside it.
That separation is the most important part of the change. If uploads need to be public, expose only the directory meant for those files, not the entire storage/ directory with logs and other internal data.
Uploading the files and switching the site's entry point
On this kind of hosting, deployment ends up being a mix of FTP/SFTP and SSH. In my case, the hosting provider's SSH access had to be enabled through the control panel before connecting:
ssh user@ftp.example.com
The application files now go into /home/user/project/.
For a new project, with the application in place and no existing public_html, create the link like this:
cd /home/user
ln -s /home/user/project/public public_html
The syntax is ln -s TARGET LINK_NAME. So public_html is the link's name, and the project's public/ directory is its destination.
This project was a little different: the old application was already sitting entirely inside public_html. So the existing directory became the project directory:
cd /home/user
mv public_html project
ln -s /home/user/project/public public_html
That mv isn't a required step in every Laravel deployment. It made sense for this migration, where project didn't exist yet. Before making this kind of switch in production, have a backup and account for the interruption between moving the directory and creating the link.
The resulting path was:
public_html → project/public
WordPress and sideproject1 in the middle of all this
The project also had a WordPress installation at /wp and another Laravel application at /sideproject1. Those URLs needed to keep working.
Two links were created inside public/:
cd /home/user/project
ln -s ../wp public/wp
ln -s ../sideproject1/public public/sideproject1
For sideproject1, the entire application stays in sideproject1/, but the domain only sees sideproject1/public/. The URL stays /sideproject1, without exposing app/, config/, .env, vendor/, and everything else alongside it.
WordPress is different: the public/wp link exposes the installation in wp/. It doesn't create the same separation that Laravel's public/ directory provides.
In this case, wp-config.php was also moved one level above the installation. I didn't know that was an option until I worked on this. WordPress still needs its own care around configuration, permissions, and accessible files, though.
The final structure looked like this:
/home/user/
├── project/
│ ├── app/
│ ├── config/
│ ├── database/
│ ├── storage/
│ ├── vendor/
│ ├── wp/
│ ├── wp-config.php
│ ├── sideproject1/
│ │ ├── app/
│ │ ├── .env
│ │ └── public/
│ ├── .env
│ ├── artisan
│ └── public/
│ ├── css/
│ ├── images/
│ ├── js/
│ ├── wp -> ../wp
│ ├── sideproject1 -> ../sideproject1/public
│ ├── robots.txt
│ ├── sitemap.xml
│ ├── cookie-policy.pdf
│ ├── index.php
│ └── .htaccess
└── public_html -> /home/user/project/public
What about .htaccess?
After the change, the .htaccess handling Laravel's entry point is the one at project/public/.htaccess.
Previously, the project root was public_html itself. Now Apache receives requests through public/, so that's where Laravel's entry point rules need to live.
Review the old rules during the switch, especially if they assumed the site was being served from the project root.
Testing what opens and what no longer does
After the change, I started with the URLs that needed to keep working:
/wp/
/sideproject1/
/admin/
/customer/login
/advertiser/login
/robots.txt
/sitemap.xml
/cookie-policy.pdf
That check also includes CSS, JavaScript, images, uploads, payments, and external callbacks. The homepage loading doesn't mean the whole deployment went well.
Then there's the other half of the test: trying to access things that shouldn't be available.
/.env
/.env.example
/artisan
/composer.json
/composer.lock
/database/database.sqlite
/storage/logs/laravel.log
/phpunit.xml
/info.php
You obviously shouldn't be able to download that pile of files anymore or open the diagnostic page. Check the response body too: the HTTP status alone doesn't tell you what the server actually returned.
Taking the files offline fixes the exposure going forward. It doesn't undo any downloads that may already have happened, so an exposed database and logs also need to be treated as an incident, including a review of the data and any potentially compromised credentials.
The deployment is still manual, but now the structure matches what Laravel expects: the browser reaches public/, and the internal files stay outside the public web root. That was the missing piece here.