nw/sftp-bundle
Symfony bundle that adds SFTP file access via phpseclib, providing a configurable client service for secure remote file operations such as listing, uploading, downloading, and deleting files from Symfony applications.
The bundle is a thin, low-ceremony wrapper around PHP's ssh2_* functions. Treat it as a convenience service, not a framework: there is no connection pooling, no retry logic, and no event integration. Because it has few stars, zero dependents, and its last release was December 2020, budget an hour to read src/ before adopting it — you will be relying on the source, not docs.
1. Check the hard requirement first. The wrapper depends on the ssh2 extension (libssh2), not phpseclib.
php -m | grep ssh2
# Debian/Ubuntu image:
# apt-get install -y libssh2-1-dev && pecl install ssh2-1.3 && docker-php-ext-enable ssh2
2. Install and register.
composer require nw/sftp-bundle
In config/bundles.php (the bundle class and namespace are defined in composer.json autoload.psr-4 — confirm the exact FQCN there, it is typically under NW\SFTPBundle):
return [
// ...
NW\SFTPBundle\NWSFTPBundle::class => ['all' => true],
];
If Composer refuses on Symfony 6/7, you will need a constraint bump or a "replace"/alias entry — see Gotchas.
3. Configure. The root key matches the bundle's config alias in DependencyInjection/Configuration.php (commonly nw_sftp or nowiko_sftp — verify before writing YAML):
# config/packages/nw_sftp.yaml
nw_sftp:
host: '%env(SFTP_HOST)%'
port: 22
username: '%env(SFTP_USER)%'
password: '%env(SFTP_PASSWORD)%'
timeout: 10
4. First use case: ship a generated artifact. The most common day-one job is "upload the nightly export." Inject the wrapper service (find its id in Resources/config/services.yml, usually something like nw_sftp.client or NW\SFTPBundle\...) and exercise it:
public function __construct(private SftpClient $sftp) {}
public function ship(string $localFile): void
{
$this->sftp->connect();
try {
if (!$this->sftp->upload($localFile, '/incoming/'.basename($localFile))) {
throw new \RuntimeException('SFTP upload failed');
}
} finally {
$this->sftp->disconnect();
}
}
Method names (connect, upload/put, download/get, delete, listDirectory) mirror the underlying ssh2_sftp_* calls; open src/ to get the exact signatures and return types — most return bool rather than throwing.
Where to look first: Resources/config/services.yml (service ids and autowiring), DependencyInjection/Configuration.php (config keys and defaults), then the single connection class. That is the whole package.
Wrap it in an application-owned interface. Do not let a bool-returning, connection-stateful object leak through your domain. Define App\Transfer\SftpUploader with a put(string $local, string $remote): void contract, implement it with the bundle service, and map false returns to exceptions. This also gives you a seam to swap in Flysystem later without touching call sites.
One connect per unit of work, always in finally. The wrapper holds a live SSH resource; a leaked handle keeps a TCP session open and counts against server MaxSessions. Pattern:
$this->sftp->connect();
try {
$this->sftp->mkdir('/incoming/'.$batch);
foreach ($files as $f) {
$this->sftp->upload($f, '/incoming/'.$batch.'/'.basename($f));
}
} finally {
$this->sftp->disconnect();
}
Drive bulk transfers from a console command. This is the natural fit — batch exports, cron-driven syncs, and manual ops. Use Symfony's ProgressBar, accept --dry-run, and log each file so a partial batch is recoverable:
#[AsCommand(name: 'app:sftp:push')]
class PushCommand extends Command { /* inject the uploader, loop, progress->advance() */ }
Go async for anything user-facing. Dispatch a TransferFile message to Messenger, put the uploader in the handler, and configure a retry_strategy with exponential backoff. Since the package has no retry of its own, Messenger gives you reconnect-on-retry for free — but the handler must call connect() at the top, because a worker reuses the process across messages and the previous connection may be dead.
Configure via env and Symfony secrets, per environment. Keep credentials out of YAML:
# config/packages/prod/nw_sftp.yaml
nw_sftp:
host: '%env(SFTP_HOST)%'
username: '%env(SFTP_USER)%'
password: '%env(resolve:SFTP_PASSWORD)%'
Multiple endpoints. The bundle is designed around a single configured connection. If you need prod/vendor/customer targets, define several service instances in services.yaml by reusing the bundle's connection class with explicit arguments, rather than trying to reconfigure the singleton at runtime — reconfiguring a shared service mid-request is where state bugs live.
Testing. Because the wrapper talks to a real socket, mock your own SftpUploader interface in unit tests. For integration tests, run a throwaway SFTP container (atmoz/sftp) and assert against the remote directory, not against mock expectations of ssh2_*.
Compose with Flysystem if you outgrow it. league/flysystem-sftp-v3 gives you the same operations with a real adapter API, retries, and a maintained surface. Keep your SftpUploader interface and the migration is a single class.
Abandonment risk is the headline gotcha. Last release 2020-12-22, 3 stars, no dependents. On Symfony 6+ expect to hit TreeBuilder deprecations (setDeprecated signatures changed), removed Definition::setPublic() defaults, and return-type mismatches in DependencyInjection/Extension. Fixes are small but you own them; fork and use a VCS repository entry in composer.json if you need patched constraints:
"repositories": [{ "type": "vcs", "url": "https://github.com/you/SFTPBundle" }]
No exceptions, just false. ssh2_* emits PHP warnings on failure and returns false. If the wrapper does not convert them, a failed upload silently looks successful in a boolean-blind caller. Always check returns and consider a temporary error handler that converts warnings to ErrorException around the call.
Host key verification is likely absent. ssh2_connect() does not validate the server fingerprint. Pin it yourself with ssh2_fingerprint($conn, SSH2_FINGERPRINT_MD5 | SSH2_FINGERPRINT_HEX) and compare against a configured constant, otherwise you have a MITM hole on every transfer.
Connection lifetime. Servers typically drop idle sessions after 5–15 minutes. In long-running Messenger workers or daemons, reconnect per message rather than holding the handle; otherwise the first transfer after a quiet period fails with "Connection closed".
Paths are relative to the login directory. ssh2_sftp resolves upload/x.txt against the chroot, while /upload/x.txt is absolute. The two are not equivalent on chrooted accounts — always log ssh2_sftp_realpath() once during bring-up to see what you are actually hitting.
Large files and memory. "Wrapper for standard PHP functions" in practice often means file_get_contents/file_put_contents internally. Check the upload/download implementation before shipping a 2 GB export; if it slurps the file, stream via ssh2_scp_send or fopen('ssh2.sftp://...') instead.
Key auth and passphrases. If you use private keys, the config key names differ from password auth (path, passphrase, possibly public_key). Passphrase-protected keys need the passphrase resolved at runtime via %env(resolve:...)%, not baked into a compiled parameter.
Permissions are silent. mkdir may succeed but leave the default umask, making files unreadable by the downstream consumer. chmod explicitly after upload, and remember ssh2_sftp_chmod takes an octal integer (0644), not a string.
Debugging workflow. Run the command with -vvv, add a temporary error_log(ssh2_fingerprint(...)) and ssh2_sftp_realpath(...) before the first operation, and tail var/log/dev.log. If connect hangs, lower timeout in config — the default may be the PHP socket default (60s), which makes failures look like deadlocks.
Extension points are limited but sufficient. Decorate the bundle service in services.yaml to add logging, metrics, or retry around each call; bind your own LoggerInterface into a replacement definition; and override the service definition entirely if you need a different connection strategy. There are no events, no middleware, and no plugin contract — decoration is the only clean seam.
How can I help you explore Laravel packages today?