anchu/ftp
Adds an FTP filesystem driver to Laravel so you can use the familiar Storage disk API to upload, download, list, and delete files on remote FTP servers. Configure a disk with your host and credentials and work with remote files like local ones.
composer require anchu/ftp
php artisan vendor:publish --provider="Anchu\Ftp\FtpServiceProvider" --tag=config
Requires ext-ftp (and ext-openssl if you plan to use FTPS). Laravel 7 supports package auto-discovery, so no manual provider/alias registration is needed — the package registers Anchu\Ftp\FtpServiceProvider and the Ftp facade for you. Confirm the file exists at config/ftp.php after publishing.
First thing to look at is the connections array in config/ftp.php. Every connection is a named array:
'connections' => [
'default' => [
'host' => env('FTP_HOST'),
'port' => env('FTP_PORT', 21),
'username' => env('FTP_USERNAME'),
'password' => env('FTP_PASSWORD'),
'passive' => env('FTP_PASSIVE', true),
'ssl' => env('FTP_SSL', false),
'timeout' => env('FTP_TIMEOUT', 90),
'root' => env('FTP_ROOT', ''), // path prefix applied to all remote paths
],
'partner' => [ /* ... */ ],
],
The first real use case is dropping a generated file onto a remote host:
use Anchu\Ftp\Facades\Ftp;
Ftp::connection('default')->put(
storage_path('app/exports/orders.csv'),
'exports/orders.csv'
);
Ftp::put(...) (no connection) targets the default connection. Paths are remote paths relative to root, not absolute filesystem paths — don't prepend the root yourself.
Treat the facade as a thin wrapper over a stateful socket. Ftp::connection('name') returns a connection object bound to that config entry; file methods handle connecting lazily. The idiomatic pattern is to wrap it in your own service so the rest of the app doesn't depend on the facade directly:
class RemoteArchive
{
public function __construct(private string $connection = 'default') {}
public function upload(string $local, string $remote): void
{
$ftp = Ftp::connection($this->connection);
$this->ensureDirectory($ftp, dirname($remote), $remote);
if (! $ftp->put($local, $remote)) {
throw new RuntimeException("Upload failed: {$remote}");
}
}
}
Directory creation is not recursive. makeDir() creates one level. Build the remote tree explicitly from dirname() parts, and guard with exists() first, because most FTP servers throw a warning rather than a clean error when the directory already exists:
$parts = explode('/', trim($dir, '/'));
$path = '';
foreach ($parts as $part) {
$path = ltrim($path . '/' . $part, '/');
if (! $ftp->exists($path)) {
$ftp->makeDir($path);
}
}
Use named connections for environment isolation. Typical setups give themselves a default (production drops) and a staging/partner connection. Keep credentials in .env per environment and always run php artisan config:cache in production — never call env() inside application code, only inside the config file.
Queue long transfers. FTP is blocking and timeout defaults to 90 seconds. For anything larger than a few MB, dispatch a job (ShouldQueue, $timeout greater than the FTP timeout) and close the connection at the end of handle() so the worker doesn't hold dead sockets across restarts:
try {
Ftp::connection('default')->put($tmp, $remote);
} finally {
Ftp::connection('default')->close();
}
Schedule recurring syncs with an Artisan command, not a closure in Kernel::schedule(), so you get retries and log context:
$schedule->command('orders:push')->hourly()->withoutOverlapping();
Listings and downloads. nlist() returns an array of raw names (server-dependent — some include . and ..); rawlist() returns the ls -l-style lines and needs parsing per server, so prefer nlist() when all you need is names. For pull-style ingestion, get() to a temp local path, process, then unlink() locally.
If you want the Storage API instead, this package isn't it — it's a procedural wrapper around ftp_*. Reach for league/flysystem-ftp and register an FTP disk if you need Storage::disk('ftp')->put(...), streaming, or assertExists() in tests. Mixing both is fine; just pick one per workflow.
--ignore-platform-reqs. Audit before adding it to a fresh project — for new work, league/flysystem-ftp is the safer long-term choice.league/flysystem-sftp-v3; don't waste time trying to force ssl => true to mean SFTP — it means explicit FTP over TLS.ssl => true needs ext-openssl and server support. Without both, ftp_ssl_connect fails silently in the connect path. Verify with a one-off script before wiring it into a schedule.passive => true is correct for almost everything behind NAT; flip to false only if the server's control connection succeeds but data transfers stall. Lower timeout to 10–15s while debugging so failures surface fast.root is a prefix, not a chroot. It prepends to remote paths but does not restrict traversal — ../ will still work if the server allows it. Don't rely on it for security.bool, not exceptions, for file ops. put, get, delete, makeDir return false on failure and may emit a PHP warning. Always check the return value; wrap the call in a small helper that converts false into an exception, otherwise failures disappear into logs. Connection failures (bad host, refused port) are the case that does throw, so guard the first call of a batch with try/catch and short-circuit the job.connection() returns a different object. Either Ftp::shouldReceive('connection')->andReturnSelf() followed by shouldReceive('put'), or — much cleaner — inject RemoteArchive-style wrappers and fake those, keeping the facade at the boundary.close() in a finally block, or re-fetch Ftp::connection(...) per job rather than caching it on a singleton.nlist() doesn't transcode UTF-8; non-ASCII names come back mangled on many servers. Stick to ASCII slugged names for anything you upload, and you'll avoid a class of "file uploaded but can't be found" bugs.get()/put() take local file paths, not streams. To upload from memory, write to a temp file (tempnam()), upload, then delete. Don't try passing an fopen() resource.exists() over catching a failed delete()/get() — servers disagree on error codes for missing files, and a pre-check keeps the failure mode consistent across hosts.How can I help you explore Laravel packages today?