PHPackages                             idct/sftp-client - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [File &amp; Storage](/categories/file-storage)
4. /
5. idct/sftp-client

ActiveLibrary[File &amp; Storage](/categories/file-storage)

idct/sftp-client
================

Typed PHP 8.2+ wrapper around ext-ssh2 that simplifies file upload/download over SSH/SCP/SFTP.

0.4.3(7y ago)25256.4k↓42%9[1 issues](https://github.com/ideaconnect/idct-sftp-client/issues)[1 PRs](https://github.com/ideaconnect/idct-sftp-client/pulls)MITPHPPHP &gt;=5.4.0CI passing

Since Jun 4Pushed 5d ago5 watchersCompare

[ Source](https://github.com/ideaconnect/idct-sftp-client)[ Packagist](https://packagist.org/packages/idct/sftp-client)[ Docs](https://github.com/ideaconnect/idct-sftp-client)[ RSS](/packages/idct-sftp-client/feed)WikiDiscussions master Synced 2d ago

READMEChangelogDependencies (1)Versions (15)Used By (0)

idct/sftp-client
================

[](#idctsftp-client)

Typed PHP 8.2+ wrapper around `ext-ssh2` that simplifies file upload / download over SSH / SCP / SFTP. Built for predictable error handling, fingerprint verification, and a clean unit-testable seam over the procedural `ssh2_*` API.

The 1.0 release adds atomic uploads, resume, recursive directory operations, streaming sources/sinks, progress callbacks, retry policies, known-hosts verification, and opt-in checksum verification — see the [feature index](#features) below.

[![codecov](https://camo.githubusercontent.com/8a2f99b6eefe59575fe307a312e4c09efda72ed012a3ee31e633dcbb66d173c1/68747470733a2f2f636f6465636f762e696f2f67682f69646561636f6e6e6563742f696463742d736674702d636c69656e742f67726170682f62616467652e7376673f746f6b656e3d424d6f5234316b587446)](https://codecov.io/gh/ideaconnect/idct-sftp-client)[![CI](https://github.com/ideaconnect/idct-sftp-client/actions/workflows/ci.yml/badge.svg)](https://github.com/ideaconnect/idct-sftp-client/actions/workflows/ci.yml)

Sponsorship ❤️
--------------

[](#sponsorship-️)

This project is maintained on the side and looking for sponsors to keep the modernization moving forward. If your team relies on it, please consider chipping in — every contribution helps keep this library alive:

[![Sponsor on GitHub](https://camo.githubusercontent.com/aeafedee84894f3c73a015782b543b4adce4035ee62c14613f7184b81ccf3047/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f73706f6e736f72732f69646561636f6e6e6563743f7374796c653d666f722d7468652d6261646765266c6f676f3d67697468756273706f6e736f7273266c6f676f436f6c6f723d7768697465266c6162656c3d53706f6e736f7226636f6c6f723d656134616161)](https://github.com/sponsors/ideaconnect)[![Buy Me a Coffee](https://camo.githubusercontent.com/1c00185448f70498dd16945671b9426b51d9f1236a52a50b810b35f94f5e9b06/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4275792532304d6525323061253230436f666665652d4646444430303f7374796c653d666f722d7468652d6261646765266c6f676f3d6275796d6561636f66666565266c6f676f436f6c6f723d626c61636b)](https://buymeacoffee.com/idct)

Thank you to everyone who already supports the project! 🙏

Contents
--------

[](#contents)

- [Sponsorship ❤️](#sponsorship-%EF%B8%8F)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Features](#features) — thematic feature index
- [Authentication](#authentication)
- [Host-key fingerprint verification](#host-key-fingerprint-verification)
- [Operations](#operations)
- [Atomic uploads &amp; resume](#atomic-uploads--resume)
- [Streaming uploads / downloads (incl. S3 sources)](#streaming-uploads--downloads-incl-s3-sources)
- [Progress tracking](#progress-tracking)
- [Recursive directory operations](#recursive-directory-operations)
- [Retry policy](#retry-policy)
- [Checksum verification](#checksum-verification)
- [Prefixes](#prefixes)
- [Error handling](#error-handling)
- [Logging](#logging)
- [Examples](#examples) — runnable scripts under [`examples/`](examples/)
- [Upgrading from 0.x](#upgrading-from-0x)
- [Development](#development)
- [License](#license)

Requirements
------------

[](#requirements)

ComponentVersionPHP`>= 8.2`ext-ssh2`>= 1.4`libssh2`>= 1.10`Install ext-ssh2 (Debian/Ubuntu):

```
sudo apt-get install php-ssh2
```

Alpine:

```
apk add php-pecl-ssh2
```

Or manually using PECL

```
pecl install ssh2
```

(depending on your OS it may be required to install the `ssh2-beta` version)

Installation
------------

[](#installation)

```
composer require idct/sftp-client:^1.0
```

Quick start
-----------

[](#quick-start)

```
use IDCT\Networking\Ssh\Auth\Credentials;
use IDCT\Networking\Ssh\SftpClient;

$client = new SftpClient();
$client->setCredentials(Credentials::withPassword('alice', 'super-secret'));
$client->connect(
    host: 'sftp.example.com',
    port: 22,
    timeoutSeconds: 5,
    expectedFingerprint: 'a1b2c3...your sha1 hex...',
);

$client->upload('/local/path/file.bin', '/remote/incoming/file.bin');
$client->download('/remote/outgoing/report.csv', '/local/reports/report.csv');

$client->close();
```

`SftpClient` implements `__destruct()` that closes the SSH session, so leaked instances still send `SSH_MSG_DISCONNECT` to the peer.

> **Verified by Behat:** [`readme.feature → Quick start round-trips a small file`](tests/functional/features/readme.feature)

Features
--------

[](#features)

TopicWhere to read moreAuthentication (password / pubkey / both / loader)[§ Authentication](#authentication)Host-key fingerprint pinning + `known_hosts` file[§ Host-key fingerprint verification](#host-key-fingerprint-verification)File ops (upload / download / SCP / stat / mkdir / rmdir / …)[§ Operations](#operations)**Atomic uploads + resume**[§ Atomic uploads &amp; resume](#atomic-uploads--resume)**Streaming sources / sinks (e.g. S3)**[§ Streaming uploads / downloads](#streaming-uploads--downloads-incl-s3-sources)**Progress callbacks**[§ Progress tracking](#progress-tracking)**Recursive directory operations**[§ Recursive directory operations](#recursive-directory-operations)**Retry policy &amp; lazy reconnect**[§ Retry policy](#retry-policy)Checksum verification (opt-in)[§ Checksum verification](#checksum-verification)Logging (PSR-3)[§ Logging](#logging)Runnable examples[`examples/`](examples/)SemVer policy[`COMPATIBILITY.md`](COMPATIBILITY.md)Disclosure policy[`SECURITY.md`](SECURITY.md)Authentication
--------------

[](#authentication)

```
use IDCT\Networking\Ssh\Auth\Credentials;
use IDCT\Networking\Ssh\Auth\StaticCredentialsLoader;

// password
Credentials::withPassword('alice', 'secret');

// public key
Credentials::withPublicKey('alice', '/path/id_rsa.pub', '/path/id_rsa', 'passphrase-or-null');

// multi-factor: both pubkey AND password legs must succeed
Credentials::withBoth('alice', 'secret', '/path/id_rsa.pub', '/path/id_rsa');

// anonymous (ssh2_auth_none)
Credentials::withNone('guest');
```

`Credentials` is a `final readonly` value object. Password and passphrase parameters are marked `#[\SensitiveParameter]` so they are redacted from PHP stack traces, and `__debugInfo()` replaces them with `***REDACTED***` in `var_dump` / `print_r` / error-log dumps.

For dynamic per-host secret resolution (Vault, AWS Secrets Manager, GCP Secret Manager, …), implement `IDCT\Networking\Ssh\Auth\CredentialsLoaderInterface` and wire it in:

```
$client->setCredentialsLoader($myVaultLoader);     // fires on every connect()
// Mutually exclusive with setCredentials().
```

For after-the-fact rate-limiting on auth failures:

```
use IDCT\Networking\Ssh\Auth\AuthFailureRateLimiter;

$client->setAuthFailureRateLimiter(new AuthFailureRateLimiter(
    thresholdFailures: 3,
    baseDelayMs: 1_000,
    maxDelayMs: 60_000,
));
```

> **Verified by Behat:**[`connect.feature`](tests/functional/features/connect.feature) (password / wrong-password / pubkey), [`readme.feature → setCredentialsLoader resolves credentials at connect-time`](tests/functional/features/readme.feature). **Unit tests:** [`AuthFailureRateLimiterTest`](tests/unit/Auth/AuthFailureRateLimiterTest.php), [`CredentialsLoaderTest`](tests/unit/Auth/CredentialsLoaderTest.php).

Host-key fingerprint verification
---------------------------------

[](#host-key-fingerprint-verification)

```
use IDCT\Networking\Ssh\HostKey\FingerprintAlgorithm;
use IDCT\Networking\Ssh\HostKey\FingerprintEncoding;

$client->connect(
    'sftp.example.com',
    22,
    timeoutSeconds: 5,
    expectedFingerprint: '5b:32:...:...',
    fingerprintAlgorithm: FingerprintAlgorithm::Sha256,   // default
    fingerprintEncoding:  FingerprintEncoding::Hex,       // default
);
```

A mismatch immediately disconnects and throws `ConnectionException` — the auth handshake never starts.

For persistent host pinning across runs, use an OpenSSH-format `known_hosts` file:

```
use IDCT\Networking\Ssh\KnownHosts\UnknownHostPolicy;

$client->connect(
    'sftp.example.com',
    22,
    knownHostsFile: '/etc/idct/known_hosts',
    onUnknownHost: UnknownHostPolicy::Reject,           // or TrustOnFirstUse
);
```

`TrustOnFirstUse` appends a custom `sha1-fpr` entry on first connection. On subsequent connects the entry is matched. A key change at the same host raises `ConnectionException` regardless of policy — mismatch is always treated as a MITM signal.

> **Why SHA-1, not SHA-256:** `SSH2_FINGERPRINT_SHA256` only ships in libssh2 ≥ 1.9; the library's floor is `ext-ssh2 >= 1.4`. The `sha1-fpr` keytype is non-standard but OpenSSH skips unknown keytypes when reading the file, so the file remains safe to share with `ssh(1)`'s own entries. Details in [`src/KnownHosts/KnownHostsFile.php`](src/KnownHosts/KnownHostsFile.php).

> **Verified by Behat:** [`known-hosts.feature`](tests/functional/features/known-hosts.feature)(TOFU first connect + repeat connect, Reject on unknown host, tampered mismatch). Fingerprint mismatch via `expectedFingerprint`: [`connect.feature`](tests/functional/features/connect.feature).

Operations
----------

[](#operations)

```
$client->upload($local, $remote);                // SFTP, atomic by default
$client->download($remote, $local);              // SFTP
$client->scpUpload($local, $remote);             // SCP (never atomic — one-shot push)
$client->scpDownload($remote, $local);           // SCP

$client->remove('/data/file.bin');
$client->rename('/data/old.bin', '/data/new.bin');

$client->makeDirectory('/data/sub', mode: 0755, recursive: true);
$client->removeDirectory('/data/sub');           // must be empty

$client->stat('/data/file.bin');                 // stat-style array
$client->fileExists('/data/file.bin');           // bool
$client->getFileList('/data');                   // list, no . / ..
$client->getFileList('/data', includeDotEntries: true);

$client->enableFileSizeVerification();           // post-transfer size check
```

> **Verified by Behat:**[`transfer.feature`](tests/functional/features/transfer.feature) (SFTP round-trip), [`ops.feature`](tests/functional/features/ops.feature) (`stat`, `fileExists`, `getFileList` ± dot entries, recursive `makeDirectory`, `rename`), [`filesystem.feature`](tests/functional/features/filesystem.feature) (mkdir / remove / rename error paths), [`needs-shell.feature`](tests/functional/features/needs-shell.feature) (`scpUpload` / `scpDownload` — runs against the OpenSSH fixture only), [`readme.feature → enableFileSizeVerification accepts a clean upload`](tests/functional/features/readme.feature).

Atomic uploads &amp; resume
---------------------------

[](#atomic-uploads--resume)

`upload()` is atomic by default: the bytes land in a hidden `.{basename}.partial-{uuid}` sibling, then `ssh2_sftp_rename`s onto the final path. Mid-transfer failures are best-effort cleaned up; nothing half-written ever appears at the destination filename. Disable on servers that reject overwrite-on-rename:

```
$client->disableAtomicUploads();
```

For resumable transfers on flaky links:

```
// Auto-detect offset from any existing partial.
$client->resumeUpload('/local/giant.iso', '/remote/giant.iso');

// Or pass an explicit byte offset.
$client->resumeUpload('/local/giant.iso', '/remote/giant.iso', offset: 1_500_000);

// Downloads mirror it.
$client->resumeDownload('/remote/giant.iso', '/local/giant.iso');
```

Behaviour:

- `resumeUpload` appends to a deterministic `.{basename}.resume`sibling (libssh2's stream-wrapper accepts `'ab'` for open but `fwrite` returns false; we use `'r+b'` + seek to dodge that quirk). On success the partial is renamed onto the final path. **On failure the partial is preserved** so the next call can pick up where this one left off — opposite of atomic upload, which unlinks the partial.
- `resumeDownload` appends to the local file; if the local already equals the remote size, it's a clean no-op.

> **Verified by Behat:**[`atomic-and-resume.feature`](tests/functional/features/atomic-and-resume.feature)(atomic round-trip, resume from server-side partial, resume download into a partial local, noop when already complete, **resume upload with explicit `offset:` arg**), [`readme.feature → disableAtomicUploads writes the destination directly (no partial)`](tests/functional/features/readme.feature). **Unit tests:** [`AtomicUploadAndResumeTest`](tests/unit/AtomicUploadAndResumeTest.php). **Runnable:** [`examples/03-resume-upload.php`](examples/03-resume-upload.php).

Streaming uploads / downloads (incl. S3 sources)
------------------------------------------------

[](#streaming-uploads--downloads-incl-s3-sources)

The plain `upload()` / `download()` take filesystem paths. For sources or sinks that aren't files — S3 objects, generated payloads, HTTP bodies, in-memory buffers — use the stream variants:

```
// Upload from any PHP stream resource.
$stream = fopen('http://my-minio:9000/bucket/key', 'rb');
$client->uploadStream($stream, '/remote/from-s3.bin');
fclose($stream);

// Download into any stream resource (returns bytes written).
$sink  = fopen('php://temp/maxmemory:0', 'r+b');
$bytes = $client->downloadStream('/remote/source.bin', $sink);
rewind($sink);
// … now hand $sink to whatever consumer wants the bytes.
```

`uploadStream` honours the atomic flag (writes to a partial, renames on success). Both methods accept an optional `ProgressListenerInterface`([§ Progress tracking](#progress-tracking)) and respect the configured chunk size (`setChunkSize(int $bytes)`).

The plan's S3-from-AWS-SDK shape is the same as the HTTP `fopen` above once you've called `\Aws\S3\S3Client::getObject(...)->get('Body')->detach()`to get the underlying stream resource — no library-side AWS dependency needed.

> **Verified by Behat:**[`s3-stream.feature`](tests/functional/features/s3-stream.feature)(round-trips a payload served by minio), [`streaming-progress.feature`](tests/functional/features/streaming-progress.feature)(uploadStream from `php://memory`, downloadStream into an in-memory sink). **Unit tests:** [`StreamingAndProgressTest`](tests/unit/StreamingAndProgressTest.php). **Runnable:** [`examples/07-stream-from-s3.php`](examples/07-stream-from-s3.php).

Progress tracking
-----------------

[](#progress-tracking)

Implement `IDCT\Networking\Ssh\Progress\ProgressListenerInterface` and pass it to any transfer:

```
use IDCT\Networking\Ssh\Progress\ProgressListenerInterface;

final class CliProgressBar implements ProgressListenerInterface
{
    public function started(string $operation, ?int $totalBytes): void
    {
        echo "[$operation] starting ($totalBytes bytes)\n";
    }
    public function progress(int $bytesDone): void
    {
        echo "\r  $bytesDone bytes …";
    }
    public function completed(int $bytesDone): void
    {
        echo "\r  done. $bytesDone bytes.\n";
    }
    public function failed(\Throwable $e): void
    {
        echo "\n  FAILED: " . $e->getMessage() . "\n";
    }
}

$client->upload('/local/big.iso', '/remote/big.iso', progress: new CliProgressBar());
```

Lifecycle contract: `started → progress × N → exactly one of completed | failed`. The terminator covers the whole operation, so atomic-rename failures fire `failed()` not a stray `completed()`. SCP transfers do NOT emit events — ext-ssh2 doesn't expose libssh2's per-chunk callbacks for `scp_send` / `scp_recv`.

Granularity is bounded by `chunkSize` (default 1 MiB). For finer-grained bars, tune it:

```
$client->setChunkSize(64 * 1024);   // 64 KiB chunks → ~16 progress events per MiB
```

> **Verified by Behat:**[`streaming-progress.feature`](tests/functional/features/streaming-progress.feature)(lifecycle started → progress → completed for "upload", final byte count). **Unit tests:** [`StreamingAndProgressTest`](tests/unit/StreamingAndProgressTest.php). **Runnable:** [`examples/02-progress.php`](examples/02-progress.php).

Recursive directory operations
------------------------------

[](#recursive-directory-operations)

```
use IDCT\Networking\Ssh\Directory\ConflictPolicy;
use IDCT\Networking\Ssh\Directory\SymlinkPolicy;

// Upload a local tree, mirroring its layout under /backup/.
$result = $client->uploadDirectory(
    '/local/src',
    '/backup/src',
    onConflict: ConflictPolicy::Skip,        // or Overwrite (default) / Fail
    symlinks:   SymlinkPolicy::Skip,         // or Follow (with cycle detection)
    bestEffort: true,                        // collect failures instead of aborting
);
echo "Uploaded {$result->filesTransferred} files, "
   . "{$result->bytesTransferred} bytes, "
   . count($result->skipped) . " skipped, "
   . count($result->failures) . " failures.\n";

// Mirror it back.
$client->downloadDirectory('/backup/src', '/local/restore');

// Walk arbitrary trees (post-order generator: children before parents).
foreach ($client->walk('/backup') as $entry) {
    echo $entry->path . " ({$entry->type->name})\n";
}

// rm -rf on the remote.
$client->removeDirectoryTree('/backup/obsolete');
```

The result types are immutable `UploadResult` / `DownloadResult` value objects with `filesTransferred`, `bytesTransferred`, `skipped`, and `failures` (a `list` populated only in best-effort mode). Per-file transfers reuse the existing `upload()` / `download()`so atomic writes, retries, file-size verification, and progress all apply on a per-file basis.

`SymlinkPolicy::Follow` is supported on the upload side with inode-set cycle detection (`(dev, ino)` tracking). On the download side, remote symlink-follow is documented as a deferred feature — the entry is recorded in `skipped` with a notice log line.

> **Verified by Behat:**[`directory-ops.feature`](tests/functional/features/directory-ops.feature)(nested round-trip + tree removal), [`readme.feature → walk() yields nested entries in post-order`](tests/functional/features/readme.feature). **Unit tests:** [`DirectoryOperationsTest`](tests/unit/DirectoryOperationsTest.php), [`Directory/DirectoryPolicyTest`](tests/unit/Directory/DirectoryPolicyTest.php) (ConflictPolicy / SymlinkPolicy / best-effort). **Runnable:** [`examples/05-upload-directory.php`](examples/05-upload-directory.php), [`examples/06-walk-and-cleanup.php`](examples/06-walk-and-cleanup.php).

Retry policy
------------

[](#retry-policy)

Every transfer is wrapped in a `RetryPolicyInterface`. The default is `ExponentialBackoffRetryPolicy` (5 retries, 200 ms base, 30 s cap, ±30% jitter). Swap or disable per-client:

```
use IDCT\Networking\Ssh\Retry\ExponentialBackoffRetryPolicy;
use IDCT\Networking\Ssh\Retry\NoRetryPolicy;

$client = new SftpClient(
    enableFileSizeVerification: false,
    ssh2: null,
    retryPolicy: new ExponentialBackoffRetryPolicy(maxRetries: 10, baseMs: 100),
);

// Or swap at runtime.
$client->setRetryPolicy(new NoRetryPolicy());
```

The retry helper:

1. **Never retries** `AuthenticationException`, `ConfigurationException`, or `InvalidPathException` — those are caller bugs, not transient.
2. **Always retries** `ConnectionException` (transport-level).
3. Retries `TransferException` only on a curated message-fragment allowlist (`Failed to copy`, `Unable to open remote`, `Could not SCP-download`, `Could not SCP-upload`) — anything else indicates real filesystem state and is propagated.
4. On each retry, if the SSH session is detected dead via `ping()`, it runs a one-shot `doConnect()` to revive it. The stored connect args are reused so transparent re-establishment is automatic.

`SftpClient::ping(): bool` is also exposed as a cheap keepalive (it stats `/` over SFTP and never throws).

To write your own policy:

```
use IDCT\Networking\Ssh\Exception\SshException;
use IDCT\Networking\Ssh\Retry\RetryPolicyInterface;

final class MyPolicy implements RetryPolicyInterface
{
    public function nextDelayMs(int $attempt, SshException $lastError): int
    {
        return $attempt > 3 ? 0 : 500;        // 500 ms each, 3 attempts max
    }
}
```

> **Verified by Behat:**[`fault-injection.feature`](tests/functional/features/fault-injection.feature)(latency + bandwidth-cap toxics: retry holds up under real adversity), [`readme.feature → setRetryPolicy(NoRetryPolicy) at runtime takes effect`](tests/functional/features/readme.feature), [`readme.feature → ping() returns true on a healthy session`](tests/functional/features/readme.feature), [`readme.feature → ping() returns false after close()`](tests/functional/features/readme.feature). **Unit tests:** [`RetryWiringTest`](tests/unit/RetryWiringTest.php), [`ExponentialBackoffRetryPolicyTest`](tests/unit/ExponentialBackoffRetryPolicyTest.php), [`NoRetryPolicyTest`](tests/unit/NoRetryPolicyTest.php). **Runnable:** [`examples/04-retry-policy.php`](examples/04-retry-policy.php).

Checksum verification
---------------------

[](#checksum-verification)

For end-to-end integrity beyond size verification, plug a `RemoteHasherInterface` implementation:

```
use IDCT\Networking\Ssh\Checksum\ShellSumRemoteHasher;
use IDCT\Networking\Ssh\Checksum\RedownloadRemoteHasher;

// Option 1: run `sha256sum` on the server (needs shell access).
$client->setRemoteHasher(new ShellSumRemoteHasher('sha256', 'sha256sum'));

// Option 2: re-download the file and hash it locally
// (no server dependency, but doubles transfer time).
$client->setRemoteHasher(new RedownloadRemoteHasher('sha256'));

// All subsequent upload / resumeUpload / download / resumeDownload
// transfers now compare local + remote digests after the transfer and
// throw TransferException on mismatch.
$client->upload('/local/critical.bin', '/remote/critical.bin');
```

> **Verified by Behat:**[`readme.feature → RedownloadRemoteHasher passes on a clean round-trip`](tests/functional/features/readme.feature), [`needs-shell.feature → ShellSumRemoteHasher verifies a clean round-trip using sha256sum`](tests/functional/features/needs-shell.feature)(runs against the OpenSSH fixture only — see Operations note). **Unit tests:** [`Checksum/RemoteHasherTest`](tests/unit/Checksum/RemoteHasherTest.php).

Prefixes
--------

[](#prefixes)

```
$client->setLocalPrefix('/var/local/inbox/');
$client->setRemotePrefix('/uploads/');

$client->upload('/var/sources/report.csv');                   // → /uploads/report.csv (basename)
$client->upload('/var/sources/report.csv', 'q3/report.csv');  // → /uploads/q3/report.csv
$client->upload('/var/sources/report.csv', '/abs/dest.csv');  // → /abs/dest.csv (absolute bypasses prefix)
```

`setRemotePrefix` is applied to BOTH sides of `rename()` (the original 0.x applied it only to the source — that was bug B6, fixed in 1.0).

Every remote path (with or without a prefix) goes through `PathValidator`before any SFTP call. Rejected inputs: null bytes (`\0`), CR/LF and other C0/C1 control characters, `.` / `..` components, paths over 4096 bytes by default. Absolute paths bypass the configured remote prefix rather than concatenating to it (T6 contract); the joined result is re-validated so attackers can't smuggle traversal *through* the prefix.

> **Verified by Behat:**[`malicious-paths.feature`](tests/functional/features/malicious-paths.feature)(T1 traversal / T3 CR-LF / T4 length / T5 dot-component / T6 prefix bypass against the live atmoz/sftp container), [`readme.feature → setRemotePrefix applies to relative paths`](tests/functional/features/readme.feature), [`readme.feature → an absolute remote path bypasses the remote prefix (T6 contract)`](tests/functional/features/readme.feature). **Unit tests:** [`PathValidatorTest`](tests/unit/PathValidatorTest.php) + [`Path/PathValidatorPropertyTest`](tests/unit/Path/PathValidatorPropertyTest.php) (T2 null-byte and other byte-level cases — `.feature` files can't carry those bytes literally).

Error handling
--------------

[](#error-handling)

All errors are typed exceptions extending `IDCT\Networking\Ssh\Exception\SshException`(itself a `RuntimeException`):

ExceptionWhen`ConfigurationException`Missing credentials, bad mode, invalid key path`ConnectionException`TCP/handshake failure, fingerprint mismatch, no SFTP session yet`AuthenticationException``ssh2_auth_*` rejected the credentials`TransferException`Upload / download / SCP failure`RemoteFilesystemException`Remote stat / mkdir / rmdir / rename / unlink / list failure`InvalidPathException`Path validation rejected the input (extends `ConfigurationException`)```
use IDCT\Networking\Ssh\Exception;

try {
    $client->download('/data/big.bin', '/local/big.bin');
} catch (Exception\ConnectionException) {
    // reconnect
} catch (Exception\TransferException $e) {
    // log, alert, retry
} catch (Exception\SshException $e) {
    // anything else from this library — single SshException root grabs all of them
}
```

> **Verified by Behat:** [`readme.feature → Every library error is caught by the SshException root`](tests/functional/features/readme.feature). **Unit tests:** [`Exception/ExceptionHierarchyTest`](tests/unit/Exception/ExceptionHierarchyTest.php).

Logging
-------

[](#logging)

```
$client->setLogger(new Monolog\Logger('sftp'));
$client->setLogContext(['request_id' => 'abc-123', 'tenant' => 'acme']);
```

`SftpClient` implements `Psr\Log\LoggerAwareInterface` (defaults to `NullLogger`). Every record carries a `correlation_id` (per-connection 16-char hex), `host`, and `port` for downstream log aggregation. A lint test ([`tests/unit/LoggerRedactionLintTest.php`](tests/unit/LoggerRedactionLintTest.php)) fails the build if any source line contains `password` / `passphrase` near a log call.

> **Verified by Behat:** [`readme.feature → setLogContext merges static fields into every record`](tests/functional/features/readme.feature)(asserts every captured record carries the caller-supplied `tenant` + `request_id` keys plus the auto-injected `correlation_id`). **Unit tests:** [`LoggerIntegrationTest`](tests/unit/LoggerIntegrationTest.php).

Examples
--------

[](#examples)

Runnable scripts live in [`examples/`](examples/). They connect to the dockerised SFTP fixture used by the Behat suite — see [`examples/README.md`](examples/README.md) for the one-line bring-up command.

> **Verified by Behat:** The "Verified by Behat" callouts under each section point at the specific scenario(s) backing the example. The README-mirroring scenarios live in [`readme.feature`](tests/functional/features/readme.feature); per-area behaviour matrices live alongside (`ops.feature`, `directory-policies.feature`, etc.). One small subset — [`needs-shell.feature`](tests/functional/features/needs-shell.feature)covering `scpUpload` / `scpDownload` / `ShellSumRemoteHasher` — runs only against the OpenSSH fixture (port 2223): the default atmoz/sftp fixture is chrooted to `internal-sftp`, so it can't exec `scp` or `sha256sum`. CI runs both backends.

\#ScriptWhat it shows01[`01-basic.php`](examples/01-basic.php)Round-trip a file via SFTP02[`02-progress.php`](examples/02-progress.php)CLI progress bar via `ProgressListenerInterface`03[`03-resume-upload.php`](examples/03-resume-upload.php)Abort an upload mid-stream and resume04[`04-retry-policy.php`](examples/04-retry-policy.php)Customise `ExponentialBackoffRetryPolicy`05[`05-upload-directory.php`](examples/05-upload-directory.php)Recursive upload with `ConflictPolicy::Skip`06[`06-walk-and-cleanup.php`](examples/06-walk-and-cleanup.php)`walk()` + `removeDirectoryTree()`07[`07-stream-from-s3.php`](examples/07-stream-from-s3.php)`uploadStream` an HTTP object served by minioUpgrading from 0.x
------------------

[](#upgrading-from-0x)

0.x1.x`new AuthMode::PASSWORD` (class const)`AuthMode::Password` (`enum` under `Auth\`)`new Credentials(); ->setMode(); ->setUsername(); …``Credentials::withPassword($u, $p)` etc.`\Exception` everywheretyped `Exception\*` hierarchy under one `SshException` root`$client->connect($host, $port)`same, plus `timeoutSeconds`, `expectedFingerprint`, `fingerprintAlgorithm`, `fingerprintEncoding`, `knownHostsFile`, `onUnknownHost`, `securityProfile``Credentials::withPublicKey(...)` accepted any stringnow validates that the key files exist`rename($from, $to)` applied prefix only to `$from`applies prefix to both sides`getFileList()` returned `.` and `..`filtered by default; `includeDotEntries: true` to keep`close()` ran `ssh2_exec($conn, 'logout')` (broken)uses `ssh2_disconnect()`Upload landed bytes directly at the destinationatomic `.partial-{uuid}` + rename by defaultClass moves: everything under `IDCT\Networking\Ssh\…` is now grouped by domain (`Auth\`, `HostKey\`, `Retry\`, `Path\`, `Progress\`, `Ssh2\`, `Directory\`, `KnownHosts\`, `Checksum\`, `Security\`). The [`CHANGELOG.md`](CHANGELOG.md) "P1 (revised)" section has the full old→new FQN table.

See [`CHANGELOG.md`](CHANGELOG.md) for the per-version history. 1.0 bundles the original modernization pass (bug-fix list `B1`–`B12`, security baseline `S1`–`S5`) with the production-grade feature work (`P1`–`P11`) — atomic transfers, resume, recursive directory ops, streaming, progress, retry, known-hosts, and opt-in checksums.

Development
-----------

[](#development)

```
composer install
composer qa                   # cs check + phpstan + phpunit
composer cs-fix               # apply CS fixes
composer stan                 # phpstan level max
composer test                 # phpunit (100% line coverage gate)
composer infection            # mutation testing (gates at 85 MSI / 85 covered MSI)
tests/functional/bin/up       # start dockerised SFTP fixtures (atmoz + openssh + minio + toxiproxy)
composer behat                # run Behat against the fixture
SFTP_PORT=2223 composer behat # run the same suite against OpenSSH 9.x
tests/functional/bin/down     # stop fixture
```

CI runs PHP 8.2 / 8.3 / 8.4 across both atmoz and OpenSSH 9.x backends in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). The unit-test coverage gate enforces **100% line coverage** on every file except `src/Ssh2/Ssh2Functions.php` (the thin ext-ssh2 delegation layer covered by Behat).

License
-------

[](#license)

MIT — see `LICENSE`.

###  Health Score

49

—

FairBetter than 94% of packages

Maintenance64

Regular maintenance activity

Popularity45

Moderate usage in the ecosystem

Community19

Small or concentrated contributor base

Maturity56

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 66.7% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~55 days

Total

4

Last Release

2784d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/168780730?v=4)[IDCT Bartosz Pachołek](/maintainers/ideaconnect)[@ideaconnect](https://github.com/ideaconnect)

---

Top Contributors

[![bpacholek](https://avatars.githubusercontent.com/u/3039162?v=4)](https://github.com/bpacholek "bpacholek (28 commits)")[![simmstein](https://avatars.githubusercontent.com/u/520175?v=4)](https://github.com/simmstein "simmstein (6 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (4 commits)")[![pedrofornaza](https://avatars.githubusercontent.com/u/1180994?v=4)](https://github.com/pedrofornaza "pedrofornaza (2 commits)")[![ivan-pagac](https://avatars.githubusercontent.com/u/873005?v=4)](https://github.com/ivan-pagac "ivan-pagac (1 commits)")[![uzegonemad](https://avatars.githubusercontent.com/u/430255?v=4)](https://github.com/uzegonemad "uzegonemad (1 commits)")

---

Tags

sshsftpssh2scpidct

###  Code Quality

Code StylePHP CS Fixer

### Embed Badge

![Health badge](/badges/idct-sftp-client/health.svg)

```
[![Health](https://phpackages.com/badges/idct-sftp-client/health.svg)](https://phpackages.com/packages/idct-sftp-client)
```

###  Alternatives

[league/flysystem

File storage abstraction for PHP

13.6k679.9M2.5k](/packages/league-flysystem)[nicolab/php-ftp-client

A flexible FTP and SSL-FTP client for PHP. This lib provides helpers easy to use to manage the remote files.

6435.6M28](/packages/nicolab-php-ftp-client)[creocoder/yii2-flysystem

The flysystem extension for the Yii framework

2931.7M63](/packages/creocoder-yii2-flysystem)[league/flysystem-sftp-v3

SFTP filesystem adapter for Flysystem.

6134.8M156](/packages/league-flysystem-sftp-v3)[yii2mod/yii2-ftp

A flexible FTP and SSL-FTP client for PHP. This lib provides helpers easy to use to manage the remote files.

34452.5k3](/packages/yii2mod-yii2-ftp)[innoge/laravel-rclone

A sleek PHP wrapper around rclone with Laravel-style fluent API syntax

187.1k](/packages/innoge-laravel-rclone)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
