diff --git a/.gitattributes b/.gitattributes
index ec6cd3f..5d358e0 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,4 +1,4 @@
 /.github/     export-ignore
 /test/        export-ignore
 /phpunit.xml  export-ignore
-/psalm.xml    export-ignore
+/phpstan.neon export-ignore
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index 4ed43c5..f993045 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -7,7 +7,7 @@ jobs:
 
     strategy:
       matrix:
-        php: [ '8.1', '8.2', '8.3' ]
+        php: [ '8.1', '8.2', '8.3', '8.4' ]
 
     services:
       mysql:
@@ -38,18 +38,19 @@ jobs:
         uses: shivammathur/setup-php@v2
         with:
           php-version: ${{ matrix.php }}
-          tools: psalm
-          extensions: sqlsrv-5.10.1
 
       - name: Setup problem matchers for PHPUnit
         run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
 
+      - name: Validate composer.json and composer.lock
+        run: composer validate --strict
+
       - name: Install Composer dependencies
         run: composer install --no-progress
 
-      - name: Run Psalm
-        run: psalm --output-format=github
-        if: ${{ matrix.php == '8.3' }}
+      - name: Perform static analysis
+        run: composer analyze -- --error-format=github
+        if: ${{ matrix.php == '8.4' }}
 
       - name: Run PHPUnit
         run: composer test-without-mssql
diff --git a/.gitignore b/.gitignore
index 47bcc6d..735e57d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,4 @@
 
 /vendor/
 /composer.lock
-/test/src/LocalConfig.php
+/test/config.php
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f4ae950..9bf2ada 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,8 +11,8 @@ From a console in the working directory, execute `composer test` to run all unit
 
 > [!NOTE]
 > By default, database tests will attempt to run on a database named `PeachySQL`.
-> To override connection settings, create a `LocalConfig.php` class in the `test/src`
-> directory which extends `Config` and overrides the desired methods.
+> To override connection settings, create a `test/config.php` file which returns
+> an instance of `Config` with the desired property values.
 
 ## Formatting and static analysis
 
diff --git a/composer.json b/composer.json
index 18333a9..f052a57 100644
--- a/composer.json
+++ b/composer.json
@@ -21,10 +21,9 @@
     },
     "require-dev": {
         "friendsofphp/php-cs-fixer": "^3.64",
+        "phpstan/phpstan": "^2.1",
         "phpunit/phpunit": "^10.5",
-        "psalm/plugin-phpunit": "^0.19",
-        "ramsey/uuid": "^4.2.3",
-        "vimeo/psalm": "^5.26"
+        "ramsey/uuid": "^4.2.3"
     },
     "autoload": {
         "psr-4": {
@@ -40,7 +39,7 @@
         "sort-packages": true
     },
     "scripts": {
-        "analyze": "psalm",
+        "analyze": "phpstan analyze",
         "cs-fix": "php-cs-fixer fix -v",
         "test": "phpunit",
         "test-mssql": "phpunit --exclude-group mysql,pgsql",
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..6042026
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,5 @@
+parameters:
+	level: 10
+	paths:
+		- src
+		- test
diff --git a/psalm.xml b/psalm.xml
deleted file mode 100644
index 59a5961..0000000
--- a/psalm.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0"?>
-<psalm
-    errorLevel="1"
-    resolveFromConfigFile="true"
-    findUnusedBaselineEntry="true"
-    findUnusedCode="true"
-    findUnusedPsalmSuppress="false"
-    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-    xmlns="https://getpsalm.org/schema/config"
-    xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
->
-    <projectFiles>
-        <directory name="src"/>
-        <directory name="test"/>
-        <ignoreFiles>
-            <directory name="vendor"/>
-        </ignoreFiles>
-    </projectFiles>
-    <plugins>
-        <pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>
-    </plugins>
-</psalm>
diff --git a/src/Options.php b/src/Options.php
index 7ad05a7..08055f4 100644
--- a/src/Options.php
+++ b/src/Options.php
@@ -50,7 +50,10 @@ public function __construct(
         } elseif ($this->driver === 'pgsql') {
             $this->binarySelectedAsStream = true;
             $this->nativeBoolColumns = true;
-            $this->floatSelectedAsString = true;
+
+            if (PHP_VERSION_ID < 80_400) {
+                $this->floatSelectedAsString = true;
+            }
         }
     }
 }
diff --git a/src/PeachySql.php b/src/PeachySql.php
index 587fad2..cff208f 100644
--- a/src/PeachySql.php
+++ b/src/PeachySql.php
@@ -36,6 +36,7 @@ public function __construct(PDO $connection, ?Options $options = null)
     public function begin(): void
     {
         if (!$this->conn->beginTransaction()) {
+            /** @phpstan-ignore argument.type */
             throw $this->getError('Failed to begin transaction', $this->conn->errorInfo());
         }
     }
@@ -47,6 +48,7 @@ public function begin(): void
     public function commit(): void
     {
         if (!$this->conn->commit()) {
+            /** @phpstan-ignore argument.type */
             throw $this->getError('Failed to commit transaction', $this->conn->errorInfo());
         }
     }
@@ -58,6 +60,7 @@ public function commit(): void
     public function rollback(): void
     {
         if (!$this->conn->rollback()) {
+            /** @phpstan-ignore argument.type */
             throw $this->getError('Failed to roll back transaction', $this->conn->errorInfo());
         }
     }
@@ -72,10 +75,12 @@ final public function makeBinaryParam(?string $binaryStr): array
         return [$binaryStr, PDO::PARAM_LOB, 0, $driverOptions];
     }
 
-    /** @internal */
+    /**
+     * @param array{0: string, 1: int|null, 2: string|null} $error
+     * @internal
+     */
     public static function getError(string $message, array $error): SqlException
     {
-        /** @var array{0: string, 1: int|null, 2: string|null} $error */
         $code = $error[1] ?? 0;
         $details = $error[2] ?? '';
         $sqlState = $error[0];
@@ -84,18 +89,19 @@ public static function getError(string $message, array $error): SqlException
     }
 
     /**
-     * Returns a prepared statement which can be executed multiple times
+     * Returns a prepared statement which can be executed multiple times.
+     * @param list<mixed> $params
      * @throws SqlException if an error occurs
      */
     public function prepare(string $sql, array $params = []): Statement
     {
         try {
             if (!$stmt = $this->conn->prepare($sql)) {
+                /** @phpstan-ignore argument.type */
                 throw $this->getError('Failed to prepare statement', $this->conn->errorInfo());
             }
 
             $i = 0;
-            /** @psalm-suppress MixedAssignment */
             foreach ($params as &$param) {
                 $i++;
 
@@ -111,6 +117,7 @@ public function prepare(string $sql, array $params = []): Statement
                 }
             }
         } catch (\PDOException $e) {
+            /** @phpstan-ignore argument.type */
             throw $this->getError('Failed to prepare statement', $this->conn->errorInfo());
         }
 
@@ -118,7 +125,8 @@ public function prepare(string $sql, array $params = []): Statement
     }
 
     /**
-     * Prepares and executes a single query with bound parameters
+     * Prepares and executes a single query with bound parameters.
+     * @param list<mixed> $params
      */
     public function query(string $sql, array $params = []): Statement
     {
diff --git a/src/QueryBuilder/Insert.php b/src/QueryBuilder/Insert.php
index 57ded63..4b12259 100644
--- a/src/QueryBuilder/Insert.php
+++ b/src/QueryBuilder/Insert.php
@@ -29,6 +29,7 @@ public static function batchRows(array $colVals, int $maxBoundParams, int $maxRo
             $maxRowsPerQuery = $maxRows;
         }
 
+        /** @phpstan-ignore argument.type */
         return array_chunk($colVals, $maxRowsPerQuery);
     }
 
@@ -38,25 +39,18 @@ public static function batchRows(array $colVals, int $maxBoundParams, int $maxRo
      */
     public function buildQuery(string $table, array $colVals): SqlParams
     {
-        self::validateColValsStructure($colVals);
+        if (!$colVals || empty($colVals[0])) {
+            throw new \Exception('A valid array of columns/values to insert must be specified');
+        }
 
         $columns = $this->escapeColumns(array_keys($colVals[0]));
         $insert = "INSERT INTO {$table} (" . implode(', ', $columns) . ')';
 
         $valSetStr = ' (' . str_repeat('?,', count($columns) - 1) . '?),';
         $valStr = ' VALUES' . substr_replace(str_repeat($valSetStr, count($colVals)), '', -1); // remove trailing comma
-        $params = array_merge(...array_map('array_values', $colVals));
+        /** @phpstan-ignore argument.type */
+        $params = array_merge(...array_map(array_values(...), $colVals));
 
         return new SqlParams($insert . $valStr, $params);
     }
-
-    /**
-     * @throws \Exception if the column/values array does not have a valid structure
-     */
-    private static function validateColValsStructure(array $colVals): void
-    {
-        if (empty($colVals[0]) || !is_array($colVals[0])) {
-            throw new \Exception('A valid array of columns/values to insert must be specified');
-        }
-    }
 }
diff --git a/src/QueryBuilder/Select.php b/src/QueryBuilder/Select.php
index 2dbe383..99c0e5d 100644
--- a/src/QueryBuilder/Select.php
+++ b/src/QueryBuilder/Select.php
@@ -8,6 +8,7 @@
 class Select extends Query
 {
     /**
+     * @param string[] $orderBy
      * @throws \Exception if there is an invalid sort direction
      */
     public function buildOrderByClause(array $orderBy): string
@@ -19,12 +20,10 @@ public function buildOrderByClause(array $orderBy): string
         $sql = ' ORDER BY ';
 
         // [column1, column2, ...]
-        if (isset($orderBy[0])) {
-            /** @var array<int, string> $orderBy */
+        if (array_is_list($orderBy)) {
             return $sql . implode(', ', $this->escapeColumns($orderBy));
         }
 
-        /** @var array<string, string> $orderBy */
         // [column1 => direction, column2 => direction, ...]
         foreach ($orderBy as $column => $direction) {
             $column = $this->escapeIdentifier($column);
diff --git a/src/QueryBuilder/Selector.php b/src/QueryBuilder/Selector.php
index 353d04e..c3d323f 100644
--- a/src/QueryBuilder/Selector.php
+++ b/src/QueryBuilder/Selector.php
@@ -11,6 +11,9 @@ class Selector
 {
     /** @var WhereClause */
     private array $where = [];
+    /**
+     * @var string[]
+     */
     private array $orderBy = [];
     private ?int $limit = null;
     private ?int $offset = null;
@@ -35,6 +38,7 @@ public function where(array $filter): static
     }
 
     /**
+     * @param mixed[] $sort
      * @throws \Exception if called more than once
      */
     public function orderBy(array $sort): static
@@ -43,6 +47,13 @@ public function orderBy(array $sort): static
             throw new \Exception('orderBy method can only be called once');
         }
 
+        foreach ($sort as $val) {
+            if (!is_string($val)) {
+                throw new \Exception('Invalid type for sort value: ' . get_debug_type($val));
+            }
+        }
+
+        /** @var string[] $sort */
         $this->orderBy = $sort;
         return $this;
     }
diff --git a/src/QueryBuilder/Update.php b/src/QueryBuilder/Update.php
index a8b6258..deb4772 100644
--- a/src/QueryBuilder/Update.php
+++ b/src/QueryBuilder/Update.php
@@ -24,7 +24,6 @@ public function buildQuery(string $table, array $set, array $where): SqlParams
         $params = [];
         $sql = "UPDATE {$table} SET ";
 
-        /** @psalm-suppress MixedAssignment */
         foreach ($set as $column => $value) {
             $sql .= $this->escapeIdentifier($column) . ' = ?, ';
             $params[] = $value;
diff --git a/src/Statement.php b/src/Statement.php
index 8849eaf..c86c649 100644
--- a/src/Statement.php
+++ b/src/Statement.php
@@ -36,9 +36,11 @@ public function execute(): void
 
         try {
             if (!$this->stmt->execute()) {
+                /** @phpstan-ignore argument.type */
                 throw PeachySql::getError('Failed to execute prepared statement', $this->stmt->errorInfo());
             }
         } catch (PDOException $e) {
+            /** @phpstan-ignore argument.type */
             throw PeachySql::getError('Failed to execute prepared statement', $this->stmt->errorInfo());
         }
 
@@ -61,15 +63,13 @@ public function execute(): void
 
     /**
      * Returns an iterator which can be used to loop through each row in the result
-     * @return \Generator<int, array>
+     * @return \Generator<int, mixed[]>
      */
     public function getIterator(): \Generator
     {
         if ($this->stmt !== null) {
-            while (
-                /** @var array|false $row */
-                $row = $this->stmt->fetch(PDO::FETCH_ASSOC)
-            ) {
+            while ($row = $this->stmt->fetch(PDO::FETCH_ASSOC)) {
+                /** @phpstan-ignore generator.valueType */
                 yield $row;
             }
 
@@ -94,7 +94,8 @@ public function close(): void
     }
 
     /**
-     * Returns all rows selected by the query
+     * Returns all rows selected by the query.
+     * @return mixed[]
      */
     public function getAll(): array
     {
@@ -102,12 +103,14 @@ public function getAll(): array
     }
 
     /**
-     * Returns the first selected row, or null if zero rows were returned
+     * Returns the first selected row, or null if zero rows were returned.
+     * @return mixed[]|null
      */
     public function getFirst(): ?array
     {
         $row = $this->getIterator()->current();
 
+        /** @phpstan-ignore notIdentical.alwaysTrue */
         if ($row !== null) {
             $this->close(); // don't leave the SQL statement open
         }
diff --git a/test/DbTestCase.php b/test/DbTestCase.php
index 427d476..59b0f42 100644
--- a/test/DbTestCase.php
+++ b/test/DbTestCase.php
@@ -109,6 +109,10 @@ public function testBlob(): void
         $db = static::dbProvider();
         $img = file_get_contents('test/DevTheorem.png');
 
+        if ($img === false) {
+            throw new \Exception('Failed to read image');
+        }
+
         $id = $db->insertRow($this->table, [
             'name' => 'DevTheorem',
             'dob' => '2024-10-24',
@@ -122,7 +126,7 @@ public function testBlob(): void
             ->where(['user_id' => $id])->query()->getFirst();
 
         if ($db->options->binarySelectedAsStream) {
-            /** @psalm-suppress PossiblyInvalidArgument */
+            /** @phpstan-ignore argument.type */
             $row['photo'] = stream_get_contents($row['photo']);
         }
 
@@ -153,6 +157,11 @@ public function testIteratorQuery(): void
         $this->assertInstanceOf(\Generator::class, $iterator);
         $colValsCompare = [];
 
+        /** @var array{
+         *     user_id: int, name: string, dob: string, weight: string|float,
+         *     is_disabled: int|bool, uuid: string|null|resource
+         * } $row
+         */
         foreach ($iterator as $row) {
             unset($row['user_id']);
 
@@ -163,7 +172,7 @@ public function testIteratorQuery(): void
                 $row['is_disabled'] = (bool) $row['is_disabled'];
             }
             if ($options->binarySelectedAsStream && $row['uuid'] !== null) {
-                /** @psalm-suppress MixedArgument */
+                /** @phpstan-ignore argument.type */
                 $row['uuid'] = stream_get_contents($row['uuid']);
             }
 
@@ -186,7 +195,6 @@ public function testIteratorQuery(): void
         foreach ($realNames as $_row) {
             $_id = $_row['user_id'];
             $_name = $_row['name'];
-            /** @psalm-suppress MixedArrayAssignment */
             $_uuid[0] = $_row['uuid'];
             $stmt->execute();
         }
@@ -250,14 +258,14 @@ public function testInsertBulk(): void
         if ($options->binarySelectedAsStream || $options->nativeBoolColumns || $options->floatSelectedAsString) {
             /** @var array{weight: float|string, is_disabled: int|bool, uuid: string|resource} $row */
             foreach ($rows as &$row) {
-                if (!is_float($row['weight'])) {
+                if ($options->floatSelectedAsString) {
                     $row['weight'] = (float) $row['weight'];
                 }
-                if (!is_int($row['is_disabled'])) {
+                if ($options->nativeBoolColumns) {
                     $row['is_disabled'] = (int) $row['is_disabled'];
                 }
-                if (!is_string($row['uuid'])) {
-                    /** @psalm-suppress InvalidArgument */
+                if ($options->binarySelectedAsStream) {
+                    /** @phpstan-ignore argument.type */
                     $row['uuid'] = stream_get_contents($row['uuid']);
                 }
             }
@@ -274,12 +282,13 @@ public function testInsertBulk(): void
         $userId = $ids[0];
         $set = ['uuid' => $peachySql->makeBinaryParam($newUuid)];
         $peachySql->updateRows($this->table, $set, ['user_id' => $userId]);
-        /** @var array{uuid: string|resource} $updatedRow */
         $updatedRow = $peachySql->selectFrom("SELECT uuid FROM {$this->table}")
             ->where(['user_id' => $userId])->query()->getFirst();
 
-        if (!is_string($updatedRow['uuid'])) {
-            $updatedRow['uuid'] = stream_get_contents($updatedRow['uuid']); // needed for PostgreSQL
+        if ($updatedRow === null) {
+            throw new \Exception('Failed to select updated UUID');
+        } elseif ($options->binarySelectedAsStream && is_resource($updatedRow['uuid'])) {
+            $updatedRow['uuid'] = stream_get_contents($updatedRow['uuid']);
         }
 
         $this->assertSame($newUuid, $updatedRow['uuid']);
diff --git a/test/MssqlDbTest.php b/test/MssqlDbTest.php
index 920ff63..d695bf2 100644
--- a/test/MssqlDbTest.php
+++ b/test/MssqlDbTest.php
@@ -27,10 +27,10 @@ public static function dbProvider(): PeachySql
     {
         if (!self::$db) {
             $c = App::$config;
-            $server = $c->getMssqlServer();
+            $server = $c->mssqlServer;
             $connStr = getenv('MSSQL_CONNECTION_STRING');
-            $username = $c->getMssqlUsername();
-            $password = $c->getMssqlPassword();
+            $username = $c->mssqlUsername;
+            $password = $c->mssqlPassword;
 
             if ($connStr !== false) {
                 // running tests with GitHub Actions
diff --git a/test/MysqlDbTest.php b/test/MysqlDbTest.php
index 7c71f5c..9828180 100644
--- a/test/MysqlDbTest.php
+++ b/test/MysqlDbTest.php
@@ -28,7 +28,7 @@ public static function dbProvider(): PeachySql
         if (!self::$db) {
             $c = App::$config;
 
-            $pdo = new PDO($c->getMysqlDsn(), $c->getMysqlUser(), $c->getMysqlPassword(), [
+            $pdo = new PDO($c->mysqlDsn, $c->mysqlUser, $c->mysqlPassword, [
                 PDO::ATTR_EMULATE_PREPARES => false,
             ]);
 
diff --git a/test/PgsqlDbTest.php b/test/PgsqlDbTest.php
index 4e03d66..397b554 100644
--- a/test/PgsqlDbTest.php
+++ b/test/PgsqlDbTest.php
@@ -34,7 +34,7 @@ public static function dbProvider(): PeachySql
             $c = App::$config;
             $dbName = getenv('POSTGRES_HOST') !== false ? 'postgres' : 'PeachySQL';
 
-            $pdo = new PDO($c->getPgsqlDsn($dbName), $c->getPgsqlUser(), $c->getPgsqlPassword(), [
+            $pdo = new PDO($c->getPgsqlDsn($dbName), $c->pgsqlUser, $c->pgsqlPassword, [
                 PDO::ATTR_EMULATE_PREPARES => false,
             ]);
 
diff --git a/test/QueryBuilder/InsertTest.php b/test/QueryBuilder/InsertTest.php
index 196debd..571721d 100644
--- a/test/QueryBuilder/InsertTest.php
+++ b/test/QueryBuilder/InsertTest.php
@@ -55,6 +55,7 @@ public static function batchRowsTestCases(): array
     /**
      * @dataProvider batchRowsTestCases
      * @param list<array<string, string>> $colVals
+     * @param list<list<array<string, string>>> $expected
      */
     public function testBatchRows(array $colVals, int $maxParams, int $maxRows, array $expected): void
     {
diff --git a/test/bootstrap.php b/test/bootstrap.php
index 3a18502..5071992 100644
--- a/test/bootstrap.php
+++ b/test/bootstrap.php
@@ -1,13 +1,19 @@
 <?php
 
-use DevTheorem\PeachySQL\Test\src\{App, Config, LocalConfig};
+use DevTheorem\PeachySQL\Test\src\{App, Config};
 
 require 'vendor/autoload.php';
 
-if (class_exists(LocalConfig::class)) {
-    // suppress error when LocalConfig doesn't exist
-    /** @psalm-suppress MixedAssignment */
-    App::$config = new LocalConfig();
+$configFile = __DIR__ . '/config.php';
+
+if (file_exists($configFile)) {
+    $config = require $configFile;
+
+    if (!$config instanceof Config) {
+        throw new Exception('Expected config file to return Config instance');
+    }
+
+    App::$config = $config;
 } else {
     App::$config = new Config();
 }
diff --git a/test/src/Config.php b/test/src/Config.php
index e408455..010f74d 100644
--- a/test/src/Config.php
+++ b/test/src/Config.php
@@ -2,53 +2,21 @@
 
 namespace DevTheorem\PeachySQL\Test\src;
 
-/**
- * Default test config. Values can be overridden with a LocalConfig child class.
- */
 class Config
 {
-    public function getMysqlDsn(): string
-    {
-        return "mysql:host=127.0.0.1;port=3306;dbname=PeachySQL";
-    }
+    public string $mssqlServer = '(local)\SQLEXPRESS';
+    public string $mssqlUsername = '';
+    public string $mssqlPassword = '';
 
-    public function getMysqlUser(): string
-    {
-        return 'root';
-    }
+    public string $mysqlDsn = 'mysql:host=127.0.0.1;port=3306;dbname=PeachySQL';
+    public string $mysqlUser = 'root';
+    public string $mysqlPassword = '';
 
-    public function getMysqlPassword(): string
-    {
-        return '';
-    }
+    public string $pgsqlUser = 'postgres';
+    public string $pgsqlPassword = 'postgres';
 
     public function getPgsqlDsn(string $database): string
     {
         return "pgsql:host=localhost;dbname=$database";
     }
-
-    public function getPgsqlUser(): string
-    {
-        return 'postgres';
-    }
-
-    public function getPgsqlPassword(): string
-    {
-        return 'postgres';
-    }
-
-    public function getMssqlServer(): string
-    {
-        return '(local)\SQLEXPRESS';
-    }
-
-    public function getMssqlUsername(): string
-    {
-        return '';
-    }
-
-    public function getMssqlPassword(): string
-    {
-        return '';
-    }
 }