• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

markrogoyski / ipv4-subnet-calculator-php / 21015766054

15 Jan 2026 01:01AM UTC coverage: 97.956% (+0.1%) from 97.831%
21015766054

push

github

markrogoyski
Update CHANGELOG for v4.4.0 release.

623 of 636 relevant lines covered (97.96%)

83.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.85
/src/SubnetCalculatorFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace IPv4;
6

7
/**
8
 * Factory for creating SubnetCalculator instances from various input formats.
9
 *
10
 * Provides convenient static methods to create subnet calculators from:
11
 *  - CIDR notation (e.g., "192.168.1.0/24")
12
 *  - IP address and subnet mask (e.g., "192.168.1.0", "255.255.255.0")
13
 *  - IP address range (start and end addresses)
14
 *  - Host count requirements (finds optimal subnet size)
15
 *
16
 * Advanced factory methods:
17
 *  - Aggregate multiple subnets into larger CIDR blocks
18
 *  - Summarize networks into a single supernet
19
 *  - Calculate optimal prefix length for host requirements
20
 *
21
 * All methods validate inputs and return configured SubnetCalculator instances.
22
 */
23
class SubnetCalculatorFactory
24
{
25
    /**
26
     * Create SubnetCalculator from CIDR notation string (e.g., "192.168.1.0/24")
27
     *
28
     * @param string $cidr CIDR notation (IP address with prefix, e.g., "192.168.1.0/24")
29
     *
30
     * @return SubnetCalculator
31
     *
32
     * @throws \InvalidArgumentException If CIDR format is invalid (missing slash, empty prefix)
33
     * @throws \UnexpectedValueException If IP address or network size is invalid
34
     */
35
    public static function fromCidr(string $cidr): SubnetCalculator
36
    {
37
        // Validate CIDR format
38
        if (\strpos($cidr, '/') === false) {
298✔
39
            throw new \InvalidArgumentException("Invalid CIDR notation: missing '/' prefix delimiter in '{$cidr}'");
1✔
40
        }
41

42
        $parts = \explode('/', $cidr);
297✔
43

44
        if (\count($parts) !== 2) {
297✔
45
            throw new \InvalidArgumentException("Invalid CIDR notation: multiple '/' found in '{$cidr}'");
1✔
46
        }
47

48
        [$ipAddress, $prefix] = $parts;
296✔
49

50
        if ($prefix === '') {
296✔
51
            throw new \InvalidArgumentException("Invalid CIDR notation: empty prefix in '{$cidr}'");
1✔
52
        }
53

54
        if (!\is_numeric($prefix)) {
295✔
55
            throw new \InvalidArgumentException("Invalid CIDR notation: non-numeric prefix '{$prefix}' in '{$cidr}'");
1✔
56
        }
57

58
        $networkSize = (int) $prefix;
294✔
59

60
        // Let SubnetCalculator validate IP address and network size
61
        return new SubnetCalculator($ipAddress, $networkSize);
294✔
62
    }
63

64
    /**
65
     * Create SubnetCalculator from IP address and subnet mask (e.g., "192.168.1.0", "255.255.255.0")
66
     *
67
     * @param string $ipAddress  IP address in dotted quad notation
68
     * @param string $subnetMask Subnet mask in dotted quad notation (e.g., "255.255.255.0")
69
     *
70
     * @return SubnetCalculator
71
     *
72
     * @throws \InvalidArgumentException If subnet mask is invalid or non-contiguous
73
     * @throws \UnexpectedValueException If IP address is invalid
74
     */
75
    public static function fromMask(string $ipAddress, string $subnetMask): SubnetCalculator
76
    {
77
        $networkSize = self::maskToNetworkSize($subnetMask);
22✔
78

79
        return new SubnetCalculator($ipAddress, $networkSize);
14✔
80
    }
81

82
    /**
83
     * Create SubnetCalculator from IP address range (e.g., "192.168.1.0", "192.168.1.255")
84
     *
85
     * Note: The range must represent a valid CIDR block. The start IP must be the
86
     * network address and the end IP must be the broadcast address of a valid subnet.
87
     *
88
     * @param string $startIp Start IP address (network address)
89
     * @param string $endIp   End IP address (broadcast address)
90
     *
91
     * @return SubnetCalculator
92
     *
93
     * @throws \InvalidArgumentException If range does not represent a valid CIDR block
94
     * @throws \UnexpectedValueException If IP addresses are invalid
95
     */
96
    public static function fromRange(string $startIp, string $endIp): SubnetCalculator
97
    {
98
        // Validate IP addresses
99
        $startLong = self::validateAndConvertIp($startIp);
19✔
100
        $endLong = self::validateAndConvertIp($endIp);
18✔
101

102
        // Start must be <= End
103
        if ($startLong > $endLong) {
17✔
104
            throw new \InvalidArgumentException("Start IP '{$startIp}' is greater than end IP '{$endIp}'");
1✔
105
        }
106

107
        // Calculate the number of addresses in the range
108
        $rangeSize = $endLong - $startLong + 1;
16✔
109

110
        // Check if range size is a power of 2
111
        if (($rangeSize & ($rangeSize - 1)) !== 0) {
16✔
112
            throw new \InvalidArgumentException(
4✔
113
                "Range from '{$startIp}' to '{$endIp}' does not represent a valid CIDR block (size {$rangeSize} is not a power of 2)"
4✔
114
            );
115
        }
116

117
        // Calculate network size from range size
118
        $networkSize = 32 - (int) \log($rangeSize, 2);
12✔
119

120
        // Validate that start IP is properly aligned for this network size
121
        // A properly aligned network address has all host bits set to 0
122
        $mask = self::calculateSubnetMaskInt($networkSize);
12✔
123
        if (($startLong & $mask) !== $startLong) {
12✔
124
            throw new \InvalidArgumentException(
1✔
125
                "Start IP '{$startIp}' is not a valid network address for a /{$networkSize} subnet"
1✔
126
            );
127
        }
128

129
        return new SubnetCalculator($startIp, $networkSize);
11✔
130
    }
131

132
    /**
133
     * Create SubnetCalculator from IP address and number of required hosts.
134
     *
135
     * Returns the smallest subnet that can accommodate the host count.
136
     *
137
     * @param string $ipAddress Base IP address
138
     * @param int    $hostCount Number of hosts required
139
     *
140
     * @return SubnetCalculator
141
     *
142
     * @throws \InvalidArgumentException If host count is invalid (zero, negative, or too large)
143
     * @throws \UnexpectedValueException If IP address is invalid
144
     */
145
    public static function fromHostCount(string $ipAddress, int $hostCount): SubnetCalculator
146
    {
147
        // Validate host count
148
        if ($hostCount <= 0) {
15✔
149
            throw new \InvalidArgumentException("Host count must be positive, got {$hostCount}");
2✔
150
        }
151

152
        // Maximum possible hosts in IPv4 (for /1 network)
153
        // 2^31 - 2 = 2147483646
154
        if ($hostCount > 2147483646) {
13✔
155
            throw new \InvalidArgumentException("Host count {$hostCount} exceeds maximum possible hosts in IPv4");
1✔
156
        }
157

158
        // Calculate optimal network size
159
        $networkSize = self::calculateOptimalNetworkSize($hostCount);
12✔
160

161
        return new SubnetCalculator($ipAddress, $networkSize);
12✔
162
    }
163

164
    /**
165
     * Calculate the optimal CIDR prefix for a given host count.
166
     *
167
     * Returns the smallest prefix (largest network) that can accommodate
168
     * the specified number of hosts.
169
     *
170
     * For standard networks (/1 to /30), usable hosts = 2^(32-prefix) - 2
171
     * For /31 (RFC 3021), usable hosts = 2
172
     * For /32, usable hosts = 1
173
     *
174
     * @param int $hostCount Number of hosts required
175
     *
176
     * @return int Optimal CIDR prefix (network size)
177
     *
178
     * @throws \InvalidArgumentException If host count is invalid (zero, negative, or too large)
179
     *
180
     * @link https://datatracker.ietf.org/doc/html/rfc3021 RFC 3021 - Using 31-Bit Prefixes on IPv4 Point-to-Point Links
181
     */
182
    public static function optimalPrefixForHosts(int $hostCount): int
183
    {
184
        // Validate host count
185
        if ($hostCount <= 0) {
29✔
186
            throw new \InvalidArgumentException("Host count must be positive, got {$hostCount}");
2✔
187
        }
188

189
        // Maximum possible hosts in IPv4 (for /1 network)
190
        // 2^31 - 2 = 2147483646
191
        if ($hostCount > 2147483646) {
27✔
192
            throw new \InvalidArgumentException("Host count {$hostCount} exceeds maximum possible hosts in IPv4");
2✔
193
        }
194

195
        return self::calculateOptimalNetworkSize($hostCount);
25✔
196
    }
197

198
    /**
199
     * Convert a subnet mask to a network size (CIDR prefix)
200
     *
201
     * @param string $subnetMask Subnet mask in dotted quad notation
202
     *
203
     * @return int Network size (CIDR prefix)
204
     *
205
     * @throws \InvalidArgumentException If mask is invalid or non-contiguous
206
     */
207
    private static function maskToNetworkSize(string $subnetMask): int
208
    {
209
        // Validate format
210
        $quads = \explode('.', $subnetMask);
22✔
211
        if (\count($quads) !== 4) {
22✔
212
            throw new \InvalidArgumentException("Invalid subnet mask format: '{$subnetMask}'");
2✔
213
        }
214

215
        // Validate each octet and convert to integer
216
        $maskInt = 0;
20✔
217
        foreach ($quads as $i => $quad) {
20✔
218
            if (!\is_numeric($quad)) {
20✔
219
                throw new \InvalidArgumentException("Invalid subnet mask: non-numeric octet in '{$subnetMask}'");
1✔
220
            }
221
            $octet = (int) $quad;
20✔
222
            if ($octet < 0 || $octet > 255) {
20✔
223
                throw new \InvalidArgumentException("Invalid subnet mask: octet out of range in '{$subnetMask}'");
1✔
224
            }
225
            $maskInt = ($maskInt << 8) | $octet;
19✔
226
        }
227

228
        // Check for zero mask (invalid - would be /0)
229
        if ($maskInt === 0) {
18✔
230
            throw new \InvalidArgumentException("Invalid subnet mask: zero mask not supported");
1✔
231
        }
232

233
        // Validate that mask is contiguous (all 1s followed by all 0s)
234
        // A valid mask in binary looks like: 11111111111111111111111100000000
235
        // If we invert it and add 1, we should get a power of 2
236
        $inverted = ~$maskInt & 0xFFFFFFFF;
17✔
237
        if (($inverted & ($inverted + 1)) !== 0) {
17✔
238
            throw new \InvalidArgumentException("Invalid subnet mask: non-contiguous mask '{$subnetMask}'");
3✔
239
        }
240

241
        // Count the number of 1 bits (network size)
242
        $networkSize = 0;
14✔
243
        $tempMask = $maskInt;
14✔
244
        while ($tempMask & 0x80000000) {
14✔
245
            $networkSize++;
14✔
246
            $tempMask <<= 1;
14✔
247
            $tempMask &= 0xFFFFFFFF; // Keep it as 32-bit
14✔
248
        }
249

250
        return $networkSize;
14✔
251
    }
252

253
    /**
254
     * Validate an IP address and convert to long integer
255
     *
256
     * @param string $ipAddress IP address to validate
257
     *
258
     * @return int IP address as integer
259
     *
260
     * @throws \UnexpectedValueException If IP address is invalid
261
     */
262
    private static function validateAndConvertIp(string $ipAddress): int
263
    {
264
        if (!\filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
19✔
265
            throw new \UnexpectedValueException("Invalid IP address: '{$ipAddress}'");
2✔
266
        }
267

268
        $long = \ip2long($ipAddress);
18✔
269
        if ($long === false) {
18✔
270
            // @codeCoverageIgnore
271
            throw new \UnexpectedValueException("Invalid IP address: '{$ipAddress}'");
×
272
        }
273

274
        // Handle negative values on 32-bit systems
275
        if ($long < 0) {
18✔
276
            // @codeCoverageIgnore
277
            $long = $long + 4294967296;
×
278
        }
279

280
        return $long;
18✔
281
    }
282

283
    /**
284
     * Calculate subnet mask as integer from network size
285
     *
286
     * @param int $networkSize Network size (1-32)
287
     *
288
     * @return int Subnet mask as integer
289
     */
290
    private static function calculateSubnetMaskInt(int $networkSize): int
291
    {
292
        if ($networkSize === 0) {
12✔
293
            return 0;
1✔
294
        }
295
        return (0xFFFFFFFF << (32 - $networkSize)) & 0xFFFFFFFF;
11✔
296
    }
297

298
    /**
299
     * Aggregate multiple subnets into the smallest possible supernet(s).
300
     *
301
     * Combines contiguous subnets into larger summary routes to reduce routing
302
     * table size. Returns an array of SubnetCalculator objects representing
303
     * the minimal set of CIDR blocks that cover all input subnets.
304
     *
305
     * If subnets are not fully contiguous, multiple supernets will be returned.
306
     * Overlapping and duplicate subnets are handled by removing redundant entries.
307
     *
308
     * @param SubnetCalculator[] $subnets Array of subnets to aggregate
309
     *
310
     * @return SubnetCalculator[] Array of aggregated supernets
311
     *
312
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
313
     */
314
    public static function aggregate(array $subnets): array
315
    {
316
        if (empty($subnets)) {
27✔
317
            return [];
1✔
318
        }
319

320
        // Normalize all subnets to their network addresses and collect as [start, end, prefix] tuples
321
        $ranges = [];
26✔
322
        foreach ($subnets as $subnet) {
26✔
323
            $start = self::ipToUnsigned($subnet->getNetworkPortionInteger());
26✔
324
            $end = $start + $subnet->getNumberIPAddresses() - 1;
26✔
325
            $ranges[] = [$start, $end, $subnet->getNetworkSize()];
26✔
326
        }
327

328
        // Sort by start address, then by end address (larger ranges first)
329
        \usort($ranges, function ($a, $b) {
330
            if ($a[0] !== $b[0]) {
25✔
331
                return $a[0] <=> $b[0];
23✔
332
            }
333
            return $b[1] <=> $a[1]; // Larger ranges first when same start
2✔
334
        });
26✔
335

336
        // Remove subnets contained within others
337
        $filtered = [];
26✔
338
        foreach ($ranges as $range) {
26✔
339
            $isContained = false;
26✔
340
            foreach ($filtered as $existing) {
26✔
341
                if ($range[0] >= $existing[0] && $range[1] <= $existing[1]) {
25✔
342
                    $isContained = true;
3✔
343
                    break;
3✔
344
                }
345
            }
346
            if (!$isContained) {
26✔
347
                // Also remove any existing ranges that this one contains
348
                $filtered = \array_filter($filtered, function ($existing) use ($range) {
349
                    return !($existing[0] >= $range[0] && $existing[1] <= $range[1]);
22✔
350
                });
26✔
351
                $filtered[] = $range;
26✔
352
            }
353
        }
354

355
        // Re-sort after filtering
356
        \usort($filtered, function ($a, $b) {
357
            return $a[0] <=> $b[0];
22✔
358
        });
26✔
359

360
        // Convert back to [start, prefix] format for aggregation
361
        $blocks = [];
26✔
362
        foreach ($filtered as $range) {
26✔
363
            $blocks[] = [$range[0], $range[2]];
26✔
364
        }
365

366
        // Iteratively merge adjacent subnets
367
        $merged = true;
26✔
368
        while ($merged) {
26✔
369
            $merged = false;
26✔
370
            $newBlocks = [];
26✔
371
            $used = [];
26✔
372

373
            for ($i = 0; $i < \count($blocks); $i++) {
26✔
374
                if (isset($used[$i])) {
26✔
375
                    continue;
19✔
376
                }
377

378
                $current = $blocks[$i];
26✔
379
                $foundMerge = false;
26✔
380

381
                // Try to find a partner to merge with
382
                for ($j = $i + 1; $j < \count($blocks); $j++) {
26✔
383
                    $other = $blocks[$j];
22✔
384

385
                    // Check if they can be merged (but not below /1 since /0 is not supported)
386
                    if ($current[1] === $other[1] && $current[1] > 1) {
22✔
387
                        $prefix = $current[1];
21✔
388
                        $blockSize = 1 << (32 - $prefix);
21✔
389

390
                        // Check if they are adjacent
391
                        $currentEnd = $current[0] + $blockSize - 1;
21✔
392
                        if ($currentEnd + 1 === $other[0]) {
21✔
393
                            // Check if the merged block would be aligned
394
                            $newPrefix = $prefix - 1;
20✔
395
                            $newBlockSize = 1 << (32 - $newPrefix);
20✔
396
                            if (($current[0] % $newBlockSize) === 0) {
20✔
397
                                // Merge them
398
                                $newBlocks[] = [$current[0], $newPrefix];
19✔
399
                                $used[$i] = true;
19✔
400
                                $used[$j] = true;
19✔
401
                                $merged = true;
19✔
402
                                $foundMerge = true;
19✔
403
                                break;
19✔
404
                            }
405
                        }
406
                    }
407
                }
408

409
                if (!$foundMerge) {
26✔
410
                    $newBlocks[] = $current;
26✔
411
                    $used[$i] = true;
26✔
412
                }
413
            }
414

415
            $blocks = $newBlocks;
26✔
416

417
            // Re-sort blocks by start address
418
            \usort($blocks, function ($a, $b) {
419
                return $a[0] <=> $b[0];
13✔
420
            });
26✔
421
        }
422

423
        // Convert back to SubnetCalculator objects
424
        $result = [];
26✔
425
        foreach ($blocks as $block) {
26✔
426
            $ip = self::unsignedToIp($block[0]);
26✔
427
            $result[] = new SubnetCalculator($ip, (int) $block[1]);
26✔
428
        }
429

430
        return $result;
26✔
431
    }
432

433
    /**
434
     * Find the smallest single supernet that contains all given subnets.
435
     *
436
     * Returns the minimal CIDR block that encompasses all input subnets.
437
     * Unlike aggregate(), this always returns a single subnet, but may include
438
     * addresses not in any of the input subnets (gaps are filled).
439
     *
440
     * @param SubnetCalculator[] $subnets Array of subnets to summarize
441
     *
442
     * @return SubnetCalculator The smallest supernet containing all inputs
443
     *
444
     * @throws \InvalidArgumentException If the subnet array is empty
445
     *
446
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
447
     */
448
    public static function summarize(array $subnets): SubnetCalculator
449
    {
450
        if (empty($subnets)) {
12✔
451
            throw new \InvalidArgumentException('Cannot summarize empty subnet array');
1✔
452
        }
453

454
        if (\count($subnets) === 1) {
11✔
455
            $subnet = $subnets[0];
1✔
456
            return new SubnetCalculator($subnet->getNetworkPortion(), $subnet->getNetworkSize());
1✔
457
        }
458

459
        // Find the minimum start IP and maximum end IP
460
        $minStart = null;
10✔
461
        $maxEnd = null;
10✔
462

463
        foreach ($subnets as $subnet) {
10✔
464
            $start = self::ipToUnsigned($subnet->getNetworkPortionInteger());
10✔
465
            $end = $start + $subnet->getNumberIPAddresses() - 1;
10✔
466

467
            if ($minStart === null || $start < $minStart) {
10✔
468
                $minStart = $start;
10✔
469
            }
470
            if ($maxEnd === null || $end > $maxEnd) {
10✔
471
                $maxEnd = $end;
10✔
472
            }
473
        }
474

475
        // Find the smallest prefix that covers this range
476
        // Start from /32 and work backwards until we find one that covers the range
477
        for ($prefix = 32; $prefix >= 1; $prefix--) {
10✔
478
            $blockSize = 1 << (32 - $prefix);
10✔
479
            // Find the network address for this prefix that contains minStart
480
            $networkStart = ((int) ($minStart / $blockSize)) * $blockSize;
10✔
481
            $networkEnd = $networkStart + $blockSize - 1;
10✔
482

483
            if ($networkStart <= $minStart && $networkEnd >= $maxEnd) {
10✔
484
                $ip = self::unsignedToIp((int) $networkStart);
9✔
485
                return new SubnetCalculator($ip, $prefix);
9✔
486
            }
487
        }
488

489
        // If we get here, the subnets span the entire IP space and would require /0
490
        throw new \InvalidArgumentException(
1✔
491
            'Cannot summarize: result would require /0 which is not a valid network size'
1✔
492
        );
493
    }
494

495
    /**
496
     * Convert a signed IP integer to unsigned for comparison
497
     *
498
     * @param int $ip Signed IP integer
499
     *
500
     * @return int Unsigned IP integer
501
     */
502
    private static function ipToUnsigned(int $ip): int
503
    {
504
        if ($ip < 0) {
×
505
            return $ip + 4294967296;
×
506
        }
507
        return $ip;
×
508
    }
509

510
    /**
511
     * Convert an unsigned IP integer back to a dotted-quad string
512
     *
513
     * @param int $ip Unsigned IP integer
514
     *
515
     * @return string Dotted-quad IP address
516
     */
517
    private static function unsignedToIp(int $ip): string
518
    {
519
        // Convert to signed for long2ip if needed
520
        if ($ip > 2147483647) {
×
521
            $ip = $ip - 4294967296;
×
522
        }
523
        $result = \long2ip($ip);
×
524
        if (!$result) {
×
525
            throw new \RuntimeException("Failed to convert IP integer to string: {$ip}");
×
526
        }
527
        return $result;
×
528
    }
529

530
    /**
531
     * Calculate the optimal network size for a given host count
532
     *
533
     * For standard networks (/1 to /30), usable hosts = 2^(32-prefix) - 2
534
     * For /31 (RFC 3021), usable hosts = 2
535
     * For /32, usable hosts = 1
536
     *
537
     * @param int $hostCount Number of hosts required
538
     *
539
     * @return int Optimal network size (CIDR prefix)
540
     */
541
    private static function calculateOptimalNetworkSize(int $hostCount): int
542
    {
543
        // Special case: 1 host needs /32
544
        if ($hostCount === 1) {
37✔
545
            return 32;
2✔
546
        }
547

548
        // Special case: 2 hosts can use /31 (RFC 3021)
549
        if ($hostCount === 2) {
35✔
550
            return 31;
2✔
551
        }
552

553
        // For 3+ hosts, we need to account for network and broadcast addresses
554
        // Usable hosts = 2^(32-prefix) - 2
555
        // So we need: 2^(32-prefix) >= hostCount + 2
556
        // Therefore: 32 - prefix >= log2(hostCount + 2)
557
        // prefix <= 32 - ceil(log2(hostCount + 2))
558

559
        $totalAddressesNeeded = $hostCount + 2;
33✔
560
        $bitsNeeded = (int) \ceil(\log($totalAddressesNeeded, 2));
33✔
561
        $networkSize = 32 - $bitsNeeded;
33✔
562

563
        // Ensure network size is valid
564
        if ($networkSize < 1) {
33✔
565
            $networkSize = 1;
1✔
566
        }
567

568
        return $networkSize;
33✔
569
    }
570
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc