ijanki/ftp-bundle
ijanki/ftp-bundle offers a lightweight FTP integration for Laravel/PHP applications, making it easy to connect to remote servers and perform common file operations. Configure your connection once, then upload, download, list, and manage files through a clean, simple API.
Verdict first: this is a Symfony2-era DI bundle whose only real payload is a thin, classy wrapper around PHP's procedural ftp_* functions. v4.0.0 does not change that — its release note is a single line quoting PHP's own 8.1 changelog ("ftp_connect returns an FTP\Connection instance now; previously, a resource was returned"). That is a version-alignment bump, not a feature release: expect a php: >=8.1 constraint in composer.json and a handful of signature updates in Ftp.php, and nothing else. There is still no Laravel service provider, no facade, no Flysystem adapter. You are not adopting a framework integration — you are adopting one class. Treat it that way: pull it in as a dev-friendly FTP client, ignore the bundle plumbing entirely, and wrap it in your own interface on day one.
1. Confirm you actually need it. In a Laravel app, Storage::disk('ftp') (Flysystem + league/flysystem-ftp) already covers put/get/list/delete/exists and is testable. Reach for this package only when you need raw FTP semantics Flysystem hides: chmod, nb_get/nb_put async transfers with progress, rawlist/MLSD parsing, systype, appending, or arbitrary server-specific commands. If your requirement is SFTP, stop — this package cannot help you; use league/flysystem-sftp-v3 or phpseclib.
2. Pin the major that matches your PHP. v4.0.0 targets PHP 8.1+; v3 and earlier expose the old resource-based API. Composer will silently resolve you to v3 if your platform is PHP 8.0, so the version you get is a function of the runner image, not your intent:
php -v
composer require ijanki/ftp-bundle:^4.0 # PHP >= 8.1
composer require ijanki/ftp-bundle:^3.0 # PHP 8.0 — resource-based API
composer show ijanki/ftp-bundle # confirm what actually landed
If any app in your fleet still runs PHP 8.0 (Laravel 9), it stays on v3 and your connection-handling code must not assume either shape — see the FTP\Connection gotcha below.
3. Check the prerequisite, not the README. The extension is bundled with PHP but is compiled out with --disable-ftp and missing on some slim images:
php -m | grep -i '^ftp$' # must print "ftp"
docker run --rm php:8.3-cli php -m | grep -i '^ftp$'
4. Install and inspect before you code. Read four files, in this order — they are the entire package:
Ftp.php — the public API. It is a near 1:1 map of the ftp_* functions, so you can predict 90% of the method names (connect, login, passive, get, fget, put, fput, nlist, rawlist, mkdir, rmdir, delete, rename, chmod, size, pwd, cd, close). On v4, diff this file against v3 specifically: type hints and anything that used to is_resource() the connection are exactly where the bump bites. Confirm the default transfer mode and whether methods return bool or $this.FtpFactory.php — how instances are constructed; tells you whether you can new Ftp() directly or need the factory.DependencyInjection/Configuration.php — the canonical config keys (host, port, username, password, passive, timeout). Copy these into a Laravel config file rather than guessing.composer.json — check the php constraint and whether it drags in symfony/framework-bundle. Run composer why symfony/framework-bundle after install; if the bundle is the only reason it is present, that is dead weight you should know about.Do not register the bundle in config/app.php. Laravel's container will not boot it, and the ijanki_ftp.* service ids will not exist. You only need the classes.
5. First working call. Bind a singleton and prove connectivity before building anything:
// app/Providers/FtpServiceProvider.php
public function register(): void
{
$this->app->singleton(FtpClient::class, fn () => new FtpClient(config('services.partner_ftp')));
}
// config/services.php
'partner_ftp' => [
'host' => env('PARTNER_FTP_HOST'),
'port' => (int) env('PARTNER_FTP_PORT', 21),
'username' => env('PARTNER_FTP_USER'),
'password' => env('PARTNER_FTP_PASS'),
'passive' => (bool) env('PARTNER_FTP_PASSIVE', true),
'timeout' => (int) env('PARTNER_FTP_TIMEOUT', 30),
],
$ftp = app(FtpClient::class)->connection();
$ftp->passive(true); // never trust the default
dump($ftp->pwd(), $ftp->systype());
dump($ftp->nlist('/outbound')); // cheap, safe read-only smoke test
$ftp->get('/outbound/prices.csv', storage_path('app/prices.csv'), FTP_BINARY);
$ftp->close();
First real use case: a scheduled php artisan ftp:pull-prices command that lists a remote drop directory, downloads anything newer than the last run, and records the filenames in a remote_files table so re-runs are idempotent. That exercises connect → passive → list → download → close, which is the whole shape of 80% of FTP work.
Wrap it behind your own contract — non-negotiable. PHP's ftp_* functions are global, so Mockery cannot mock them, and the bundle's class is concrete. Without an interface your FTP code is untestable and every call site is coupled to an abandoned package.
interface RemoteFtp
{
public function list(string $dir): array;
public function download(string $remote, string $local): void;
public function upload(string $local, string $remote): void;
public function delete(string $remote): void;
}
Implement it with the bundle; later you can swap in Flysystem, phpseclib, or a fake without touching a single job class. Note that resource is not a legal type declaration in PHP, which is why the v4 connection-object change is mostly a runtime concern rather than a compile-time one — and why you should keep the connection handle out of your interface's signatures entirely.
Connection lifecycle: one connection per unit of work, not per application. The wrapper holds a live socket (now an FTP\Connection object). In a queue worker or Octane process, a singleton that lives for hours will eventually hit the server's idle timeout (commonly 300s) and every subsequent call fails with a reset. Either bind it as scoped (Laravel 10+) or open/close inside the job:
public function handle(FtpClient $factory): void
{
$ftp = $factory->connection();
try {
// work
} finally {
$ftp->close();
}
}
If you must reuse it, liveness-check with a cheap $ftp->pwd() and reconnect on false. Do not reach for is_resource($conn) as a health check — see the gotcha below.
Atomic uploads: write to a temp name, then rename. FTP has no partial-write protection; a consumer polling the drop directory will happily read half a file.
$tmp = $remote . '.part';
$conn->fput($tmp, $handle, FTP_BINARY);
$conn->delete($remote); // some servers refuse rename-over-existing
$conn->rename($tmp, $remote);
Stream, never buffer. put()/get() take local file paths; fput()/fget() take stream resources — these are PHP streams and are unaffected by the FTP\Connection change. For anything over a few MB, pipe between disks without touching memory or disk twice:
$stream = Storage::disk('s3')->readStream($key);
$conn->fput('/inbound/' . $name, $stream, FTP_BINARY);
if (is_resource($stream)) {
fclose($stream);
}
Large transfers with the non-blocking API. nb_get/nb_put + nb_continue let you emit progress and enforce your own wall-clock budget instead of blocking until PHP's max_execution_time kills the request:
$conn->nbGet($remote, $local, FTP_BINARY);
while (($state = $conn->nbContinue()) === FTP_MOREDATA) {
// optionally: $this->output->progressAdvance();
usleep(50_000);
}
if ($state !== FTP_FINISHED) {
throw new RuntimeException("Transfer aborted for {$remote}");
}
Queue it, and only after commit. FTP is I/O against a third party; never inside a DB transaction and never synchronously in a request.
class PushExportToPartner implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 120;
public array $backoff = [10, 60, 300];
public function __construct(private string $localPath, private string $remoteName) {}
public function handle(FtpClient $factory): void
{
// ... fput to .part, delete, rename
}
public function failed(Throwable $e): void
{
Log::error('ftp push failed', ['file' => $this->remoteName, 'error' => $e->getMessage()]);
// alert / mark DB row as failed
}
}
Recursive mkdir. ftp_mkdir creates one level only. Build the path incrementally, tolerating "already exists":
function ensureDir($conn, string $path): void
{
$walk = '';
foreach (array_filter(explode('/', $path)) as $segment) {
$walk .= '/' . $segment;
$conn->mkdir($walk); // returns false if it exists — check with cd/pwd if you care
}
}
Directory diffing needs mtimes, and nlist does not give you any. Either parse rawlist (format varies wildly between vsftpd, proftpd, Pure-FTPd and IIS — avoid) or use MLSD. If the wrapper does not expose MLSD, keep a remote_state table of filename → size and compare on size; it is crude but portable and survives server swaps.
Two-connection patterns. Server-to-server copies cannot be done in one socket stream; open source and target connections, download to a temp file (tmpfile() + fget/fput), and clean up. Budget for it — you are paying for the bytes twice.
Testing. Spin up a real FTP server; do not fake the protocol.
# docker-compose.yml
services:
ftp:
image: fauria/vsftpd
environment:
FTP_USER: app
FTP_PASS: secret
PASV_ADDRESS: 127.0.0.1
ports: ["21:21", "21100-21110:21100-21110"]
Then run the integration suite against 127.0.0.1:21. Unit tests use your RemoteFtp fake — that is precisely why the interface exists. Pin the test image to the same PHP minor as production; the FTP\Connection object only exists on 8.1+, and a mixed matrix will mask exactly the class of bug v4.0.0 introduces.
v4.0.0 is a PHP 8.1 compatibility bump with a runtime-only blast radius. ftp_connect() now yields an FTP\Connection object, not a resource. Because PHP forbids resource in type declarations, nothing will fail to compile — it fails at runtime, quietly:
// PHP <= 8.0: true. PHP >= 8.1: false, always.
if (!is_resource($conn)) {
throw new RuntimeException('not connected');
}
// Correct on >= 8.1
$conn instanceof \FTP\Connection;
Audit your own code (and any escape-hatch adapter from an earlier integration) for is_resource()/get_resource_type() guards around the connection, @param resource $conn docblocks that PHPStan will now flag, and (string) $conn logging context. Streams passed to fput/fget are still resources — do not "fix" those.
Never log the connection handle. Log::debug('ftp', ['conn' => $conn]) used to print Resource id #5; on v4 it dumps the entire object graph and can blow up log volume or serialization in queue payloads. Log pwd() or a host string instead.
Silent failures are the default failure mode. ftp_* functions return false and emit an E_WARNING; there is no ftp_last_error() in PHP. Most bugs here look like "it just didn't upload." Install a temporary error handler so you can see why:
set_error_handler(function ($no, $str) { throw new ErrorException($str, 0, $no); });
try {
$conn->put($remote, $local, FTP_BINARY);
} finally {
restore_error_handler();
}
Better: wrap each call in your RemoteFtp implementation and throw a domain exception with the operation, path and connection state attached. The bundle converts some failures to exceptions and leaves others as false — audit Ftp.php method by method before you rely on try/catch, and re-audit after the 3.x → 4.x jump, since signature churn in a package this thin tends to move that boundary.
Passive mode is not optional in modern infrastructure. Behind Docker, NAT, k8s or any cloud egress, active mode will hang on the data channel. Call passive(true) explicitly every time rather than trusting whatever default your PHP build ships.
Binary vs ASCII. Default everything to FTP_BINARY. ASCII mode performs CRLF translation and will quietly corrupt images, zips and PDFs, and will also mangle checksums. The only legitimate ASCII case is uploading a plain-text file to a mainframe-era server.
nlist return values differ per server. Some return bare filenames, some return absolute paths, some include . and ... Normalize with basename() and filter dot entries before you compare against anything.
Credentials are cleartext. Plain FTP sends the password and the payload unencrypted. Over any network you do not control, use FTPS/SFTP instead. At minimum: env vars only, never committed, and rotate the partner password on offboarding. Note this wrapper does not appear to expose ftp_ssl_connect — if you need explicit FTPS, you are probably writing your own adapter.
chmod and rename are advisory. Both depend entirely on the server and the account's permissions. Never treat chmod returning true as proof the mode was applied; size() after chmod is the only reliable follow-up check.
Server-side idle timeouts bite queue workers. Classic symptom: the first job after deploy works, the tenth fails with "Connection reset by peer." Fix by opening a connection per job (as above) or pinging with pwd() before each operation.
ftp_pasv + firewalls need a port range. If uploads connect but data transfers hang, your passive port range is blocked. fauria/vsftpd uses 21100–21110; Azure/AWS load balancers need that range opened or the server configured to a fixed narrow window.
Timeouts are set on the connection, not the wrapper. ftp_set_option($connection, FTP_TIMEOUT_SEC, 30) — if the wrapper exposes setOption()/getOption(), use it; otherwise your $config['timeout'] may only cover connect() and not the transfer. On v4 the first argument is the FTP\Connection object; if you carry any settype/is_resource pre-flight assertion here, it is now dead code that silently skips the timeout.
Check the autoload layout after any major bump. Symfony2-era packages sometimes use PSR-0 (iJanki/Bundle/FtpBundle/...) rather than PSR-4, and majors are exactly when a namespace or directory move lands. If class-not-found errors appear after composer require, read vendor/ijanki/ftp-bundle/composer.json and the actual directory tree before assuming your own code is wrong. Also clear composer.lock-pinned opcache/APCu class caches in production.
Escape hatch. For anything the wrapper omits (ftp_mlsd, ftp_append, ftp_set_option, ftp_ssl_connect), do not fork the bundle. Write a five-line adapter in your own namespace that opens a native connection and calls the function directly, and have both implementations satisfy RemoteFtp. On PHP 8.1+ those native calls hand you FTP\Connection too — keep the handle untyped in your adapter (private $connection; with a docblock) so the same file works on 8.0 and 8.1. This also gives you a clean migration path off the package when it finally stops working with a future PHP version.
Extension points worth knowing: the wrapper's constructor and factory are the only seams. There is no event dispatcher, no retry policy, no logger injection. All observability — logging every command, timing every transfer, metrics on bytes moved — is yours to add in the decorator around RemoteFtp. Do that on day one; retrofitting logging into 30 call sites later is the tax this package will otherwise charge you. The v4.0.0 changelog is also a useful reminder of the maintenance cadence: this project ships a major when the resource → object migration is forced on it, not when features are added. Expect the next such bump whenever PHP touches the ftp_* signatures again, and keep your ^4.0 constraint narrow enough that the upgrade is a deliberate act.
How can I help you explore Laravel packages today?