33Downloads and sets up a specified PHP version on Windows.
44
55. PARAMETER Version
6- Major.minor or full version (e.g., 7.4 or 7.4.30).
7-
8- . PARAMETER Path
9- Destination directory (defaults to C:\php<Version>).
6+ Major.minor or full version (e.g., 8.4 or 8.4.15).
107
118. PARAMETER Arch
12- Architecture: x64 or x86 (default: x64).
9+ x64 or x86 (default: x64).
1310
1411. PARAMETER ThreadSafe
15- ThreadSafe: download Thread Safe build (default: $False).
12+ Download Thread Safe build (default: $False).
1613
1714. PARAMETER Timezone
1815date.timezone string for php.ini (default: 'UTC').
16+
17+ . PARAMETER Scope
18+ Auto (default), CurrentUser, AllUsers, or Custom.
19+ - Auto: AllUsers if elevated, otherwise CurrentUser.
20+ - AllUsers: Requires elevation, installs under Program Files (or Program Files (x86) for x86 arch).
21+ - CurrentUser: Installs under $env:LOCALAPPDATA.
22+ - Custom: Installs under -CustomPath (or prompts), adds to User PATH.
23+
24+ . PARAMETER CustomPath
25+ Directory for Scope=Custom. Versions are installed under this directory and a "current" link is created here.
26+
1927#>
2028
2129[CmdletBinding ()]
2230param (
2331 [Parameter (Mandatory = $true , Position = 0 )]
2432 [ValidatePattern (' ^\d+(\.\d+)?(\.\d+)?((alpha|beta|RC)\d*)?$' )]
2533 [string ]$Version ,
34+
2635 [Parameter (Mandatory = $false , Position = 1 )]
27- [string ]$Path = " C:\php$Version " ,
28- [Parameter (Mandatory = $false , Position = 2 )]
2936 [ValidateSet (" x64" , " x86" )]
3037 [string ]$Arch = " x64" ,
31- [Parameter (Mandatory = $false , Position = 3 )]
38+
39+ [Parameter (Mandatory = $false , Position = 2 )]
3240 [bool ]$ThreadSafe = $False ,
33- [Parameter (Mandatory = $false , Position = 4 )]
34- [string ]$Timezone = ' UTC'
41+
42+ [Parameter (Mandatory = $false , Position = 3 )]
43+ [string ]$Timezone = ' UTC' ,
44+
45+ [Parameter (Mandatory = $false )]
46+ [ValidateSet (' Auto' , ' CurrentUser' , ' AllUsers' , ' Custom' )]
47+ [string ]$Scope = ' Auto' ,
48+
49+ [Parameter (Mandatory = $false )]
50+ [string ]$CustomPath
3551)
3652
3753Function Get-File {
@@ -52,19 +68,20 @@ Function Get-File {
5268 for ($i = 0 ; $i -lt $Retries ; $i ++ ) {
5369 try {
5470 if ($OutFile -ne ' ' ) {
55- Invoke-WebRequest - Uri $Url - OutFile $OutFile - TimeoutSec $TimeoutSec
71+ Invoke-WebRequest - Uri $Url - OutFile $OutFile - TimeoutSec $TimeoutSec - UseBasicParsing - ErrorAction Stop
72+ return
5673 } else {
57- Invoke-WebRequest - Uri $Url - TimeoutSec $TimeoutSec
74+ return Invoke-WebRequest - Uri $Url - TimeoutSec $TimeoutSec - UseBasicParsing - ErrorAction Stop
5875 }
59- break ;
6076 } catch {
6177 if ($i -eq ($Retries - 1 )) {
6278 if ($FallbackUrl ) {
6379 try {
6480 if ($OutFile -ne ' ' ) {
65- Invoke-WebRequest - Uri $FallbackUrl - OutFile $OutFile - TimeoutSec $TimeoutSec
81+ Invoke-WebRequest - Uri $FallbackUrl - OutFile $OutFile - TimeoutSec $TimeoutSec - UseBasicParsing - ErrorAction Stop
82+ return
6683 } else {
67- Invoke-WebRequest - Uri $FallbackUrl - TimeoutSec $TimeoutSec
84+ return Invoke-WebRequest - Uri $FallbackUrl - TimeoutSec $TimeoutSec - UseBasicParsing - ErrorAction Stop
6885 }
6986 } catch {
7087 throw " Failed to download the file from $Url and $FallbackUrl "
@@ -77,6 +94,87 @@ Function Get-File {
7794 }
7895}
7996
97+ Function Test-IsAdmin {
98+ $p = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity ]::GetCurrent())
99+ return $p.IsInRole ([Security.Principal.WindowsBuiltInRole ]::Administrator)
100+ }
101+
102+ Function ConvertTo-BoolOrDefault {
103+ param ([string ]$UserInput , [bool ]$Default )
104+ if ([string ]::IsNullOrWhiteSpace($UserInput )) { return $Default }
105+ switch - Regex ($UserInput.Trim ().ToLowerInvariant()) {
106+ ' ^(1|true|t|y|yes)$' { return $true }
107+ ' ^(0|false|f|n|no)$' { return $false }
108+ default { return $Default }
109+ }
110+ }
111+
112+ Function Edit-PathForCompare ([string ]$p ) {
113+ if ([string ]::IsNullOrWhiteSpace($p )) { return ' ' }
114+ return ($p.Trim ().Trim(' "' ).TrimEnd(' \' )).ToLowerInvariant()
115+ }
116+
117+ Function Set-PathEntryFirst {
118+ param (
119+ [Parameter (Mandatory = $true )][ValidateSet (' User' , ' Machine' )] [string ]$Target ,
120+ [Parameter (Mandatory = $true )][string ]$Entry
121+ )
122+
123+ $entryNorm = Edit-PathForCompare $Entry
124+
125+ $existing = [Environment ]::GetEnvironmentVariable(' Path' , $Target )
126+ if ($null -eq $existing ) { $existing = ' ' }
127+
128+ $parts = @ ()
129+ foreach ($p in ($existing -split ' ;' )) {
130+ if (-not [string ]::IsNullOrWhiteSpace($p )) {
131+ if ((Edit-PathForCompare $p ) -ne $entryNorm ) { $parts += $p }
132+ }
133+ }
134+ $newParts = @ ($Entry ) + $parts
135+ [Environment ]::SetEnvironmentVariable(' Path' , ($newParts -join ' ;' ), $Target )
136+
137+ $procParts = @ ()
138+ foreach ($p in ($env: Path -split ' ;' )) {
139+ if (-not [string ]::IsNullOrWhiteSpace($p )) {
140+ if ((Edit-PathForCompare $p ) -ne $entryNorm ) { $procParts += $p }
141+ }
142+ }
143+ $env: Path = ((@ ($Entry ) + $procParts ) -join ' ;' )
144+ }
145+
146+ function Send-EnvironmentChangeBroadcast {
147+ try {
148+ $sig = @'
149+ using System;
150+ using System.Runtime.InteropServices;
151+ public static class NativeMethods {
152+ [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]
153+ public static extern IntPtr SendMessageTimeout(
154+ IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam,
155+ uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
156+ }
157+ '@
158+ Add-Type - TypeDefinition $sig - ErrorAction SilentlyContinue | Out-Null
159+ $HWND_BROADCAST = [IntPtr ]0xffff
160+ $WM_SETTINGCHANGE = 0x001A
161+ $SMTO_ABORTIFHUNG = 0x0002
162+ [UIntPtr ]$result = [UIntPtr ]::Zero
163+ [NativeMethods ]::SendMessageTimeout($HWND_BROADCAST , $WM_SETTINGCHANGE , [UIntPtr ]::Zero, " Environment" , $SMTO_ABORTIFHUNG , 5000 , [ref ]$result ) | Out-Null
164+ } catch { }
165+ }
166+
167+ Function Test-EmptyDir ([string ]$Dir ) {
168+ if (-not (Test-Path - LiteralPath $Dir )) {
169+ New-Item - ItemType Directory - Path $Dir - Force | Out-Null
170+ return
171+ }
172+ $items = Get-ChildItem - LiteralPath $Dir - Force - ErrorAction SilentlyContinue
173+ if ($items -and $items.Count -gt 0 ) {
174+ throw " The directory '$Dir ' is not empty. Please choose another location."
175+ }
176+ }
177+
80178Function Get-Semver {
81179 [CmdletBinding ()]
82180 param (
@@ -85,19 +183,22 @@ Function Get-Semver {
85183 [ValidatePattern (' ^\d+\.\d+$' )]
86184 [string ]$Version
87185 )
88- $releases = Get-File - Url " https://downloads.php.net/~windows/releases/releases.json" | ConvertFrom-Json
186+
187+ $jsonUrl = " https://downloads.php.net/~windows/releases/releases.json"
188+ $releases = ((Get-File - Url $jsonUrl ).Content | ConvertFrom-Json )
189+
89190 $semver = $releases .$Version.version
90- if ($null -eq $semver ) {
91- $semver = ( Get-File - Url " https://downloads.php.net/~windows/releases/archives " ).Links |
92- Where-Object { $_ .href -match " php-( $Version .[0-9]+).* " } |
93- ForEach-Object { $matches [ 1 ] } |
94- Sort -Object { [ System.Version ] $_ } - Descending |
95- Select-Object - First 1
96- }
97- if ( $null -eq $semver ) {
98- throw " Unsupported PHP version: $Version "
99- }
100- return $semver
191+ if ($null -ne $semver ) { return [ string ] $semver }
192+
193+ $html = ( Get-File - Url " https://downloads. php.net/~windows/releases/archives/ " ).Content
194+ $rx = [ regex ] " php-( $ ( [ regex ]::Escape( $Version ) ) \.[0-9]+) "
195+ $found = $rx .Matches ( $html ) | ForEach -Object { $_ .Groups [ 1 ].Value } |
196+ Sort-Object { [ version ] $_ } - Descending |
197+ Select-Object - First 1
198+
199+ if ( $null -ne $found ) { return [ string ] $found }
200+
201+ throw " Unsupported PHP version series: $Version "
101202}
102203
103204Function Get-VSVersion {
@@ -160,7 +261,7 @@ Function Get-PhpFromUrl {
160261 $vs = Get-VSVersion $Version
161262 $ts = if ($ThreadSafe ) { " ts" } else { " nts" }
162263 $zipName = if ($ThreadSafe ) { " php-$Semver -Win32-$vs -$Arch .zip" } else { " php-$Semver -$ts -Win32-$vs -$Arch .zip" }
163- $type = Get-ReleaseType $Version
264+ $type = Get-ReleaseType $Semver
164265
165266 $base = " https://downloads.php.net/~windows/$type "
166267 try {
@@ -175,49 +276,147 @@ Function Get-PhpFromUrl {
175276}
176277
177278$tempFile = [IO.Path ]::ChangeExtension([IO.Path ]::GetTempFileName(), ' .zip' )
279+
178280try {
281+ $isAdmin = Test-IsAdmin
282+
283+ if (-not $PSBoundParameters.ContainsKey (' Arch' )) {
284+ Write-Host " "
285+ Write-Host " What architecture would you like to install?"
286+ Write-Host " Enter x64 for 64-bit"
287+ Write-Host " Enter x86 for 32-bit"
288+ Write-Host " Press Enter to use default ($Arch )"
289+ $archSel = Read-Host " Please enter [x64/x86]"
290+ if (-not [string ]::IsNullOrWhiteSpace($archSel ) -and @ (' x64' , ' x86' ) -contains $archSel.Trim ()) {
291+ $Arch = $archSel.Trim ()
292+ }
293+ }
294+
295+ if (-not $PSBoundParameters.ContainsKey (' ThreadSafe' )) {
296+ Write-Host " "
297+ Write-Host " What ThreadSafe option would you like to use?"
298+ Write-Host " Enter true for ThreadSafe"
299+ Write-Host " Enter false for Non-ThreadSafe"
300+ Write-Host " Press Enter to use default ($ThreadSafe )"
301+ $tsSel = Read-Host " Please enter [true/false]"
302+ $ThreadSafe = ConvertTo-BoolOrDefault - UserInput $tsSel - Default $ThreadSafe
303+ }
304+
305+ if (-not $PSBoundParameters.ContainsKey (' Timezone' )) {
306+ Write-Host " "
307+ Write-Host " What timezone would you like to set in php.ini?"
308+ Write-Host " Press Enter to use default ($Timezone )"
309+ $tzSel = Read-Host " Please enter timezone"
310+ if (-not [string ]::IsNullOrWhiteSpace($tzSel )) {
311+ $Timezone = $tzSel.Trim ()
312+ }
313+ }
314+
315+ if (-not $PSBoundParameters.ContainsKey (' Scope' )) {
316+ Write-Host " "
317+ Write-Host " Would you like to install PHP for:"
318+ Write-Host " Enter 1 for Current user"
319+ Write-Host " Enter 2 for All users (requires admin elevation)"
320+ Write-Host " Enter 3 to install PHP at a custom path"
321+ Write-Host " Press Enter to choose automatically"
322+ $sel = Read-Host " Please enter [1-3]"
323+ switch ($sel ) {
324+ ' 1' { $Scope = ' CurrentUser' }
325+ ' 2' { $Scope = ' AllUsers' }
326+ ' 3' { $Scope = ' Custom' }
327+ default { $Scope = ' Auto' }
328+ }
329+ }
330+
331+ if ($Scope -eq ' Custom' -and -not $PSBoundParameters.ContainsKey (' CustomPath' )) {
332+ $defaultCustom = if ($CustomPath ) { $CustomPath } else { (Join-Path $env: LOCALAPPDATA ' Programs\PHP' ) }
333+ Write-Host " "
334+ Write-Host " Please enter the custom installation path."
335+ Write-Host " Press Enter to use default ($defaultCustom )"
336+ $cr = Read-Host " Please enter"
337+ $CustomPath = if ([string ]::IsNullOrWhiteSpace($cr )) { $defaultCustom } else { $cr.Trim () }
338+ }
339+
179340 if ($Version -match " ^\d+\.\d+$" ) {
180341 $Semver = Get-Semver $Version
342+ $MajorMinor = $Version
181343 } else {
182344 $Semver = $Version
183- $Semver -match ' ^(\d+\.\d+)' | Out-Null
184- $Version = $Matches [1 ]
345+ if ( $Semver -notmatch ' ^(\d+\.\d+)' ) { throw " Could not derive major.minor from Version ' $Version '. " }
346+ $MajorMinor = $Matches [1 ]
185347 }
186348
187- if (-not (Test-Path $Path )) {
188- try {
189- New-Item - ItemType Directory - Path $Path - ErrorAction Stop | Out-Null
190- } catch {
191- throw " Failed to create directory $Path . $_ "
349+ if ([version ]$MajorMinor -lt [version ]' 5.5' -and $Arch -eq ' x64' ) {
350+ $Arch = ' x86'
351+ Write-Host " PHP series $MajorMinor does not support x64 on Windows. Using x86."
352+ }
353+
354+ $EffectiveScope = $Scope
355+ if ($Scope -eq ' Auto' ) {
356+ $EffectiveScope = if ($isAdmin ) { ' AllUsers' } else { ' CurrentUser' }
357+ }
358+
359+ if ($EffectiveScope -eq ' AllUsers' -and -not $isAdmin ) {
360+ throw " AllUsers install selected but this session is not elevated. Re-run as Administrator or choose CurrentUser/Custom."
361+ }
362+
363+ $installRootDirectory = switch ($EffectiveScope ) {
364+ ' CurrentUser' { Join-Path $env: LOCALAPPDATA ' Programs\PHP' }
365+ ' AllUsers' {
366+ $pf = $env: ProgramFiles
367+ if ($Arch -eq ' x86' -and ${env: ProgramFiles(x86)} ) { $pf = ${env: ProgramFiles(x86)} }
368+ Join-Path $pf ' PHP'
192369 }
193- } else {
194- $files = Get-ChildItem - Path $Path
195- if ($files.Count -gt 0 ) {
196- throw " The directory $Path is not empty. Please provide an empty directory."
370+ ' Custom' {
371+ if ([string ]::IsNullOrWhiteSpace($CustomPath )) { throw " Scope=Custom requires -CustomPath (or interactive input)." }
372+ [Environment ]::ExpandEnvironmentVariables($CustomPath )
197373 }
374+ default { throw " Unexpected scope: $EffectiveScope " }
198375 }
199376
200- if ($Version -lt ' 5.5' -and $Arch -eq ' x64' ) {
201- $Arch = ' x86'
202- Write-Host " PHP version $Version does not support x64 architecture on Windows. Using x86 instead."
377+ if (-not (Test-Path - LiteralPath $installRootDirectory )) {
378+ New-Item - ItemType Directory - Path $installRootDirectory | Out-Null
203379 }
204380
205- Write-Host " Downloading PHP $Semver to $Path "
206- Get-PhpFromUrl $Version $Semver $Arch $ThreadSafe $tempFile
207- Expand-Archive - Path $tempFile - DestinationPath $Path - Force - ErrorAction Stop
381+ $tsTag = if ($ThreadSafe ) { ' ts' } else { ' nts' }
382+ $installDirectory = Join-Path (Join-Path (Join-Path $installRootDirectory $Semver ) $tsTag ) $Arch
383+ $currentLink = Join-Path $installRootDirectory ' current'
384+
385+ Test-EmptyDir $installDirectory
386+
387+ Write-Host " Downloading PHP $Semver ($Arch , $tsTag ) -> $installDirectory "
388+ Get-PhpFromUrl $MajorMinor $Semver $Arch $ThreadSafe $tempFile
389+
390+ Expand-Archive - Path $tempFile - DestinationPath $installDirectory - Force - ErrorAction Stop
208391
209- $phpIniProd = Join-Path $Path " php.ini-production"
392+ $phpIniProd = Join-Path $installDirectory " php.ini-production"
210393 if (-not (Test-Path $phpIniProd )) {
211- $phpIniProd = Join-Path $Path " php.ini-recommended"
394+ $phpIniProd = Join-Path $installDirectory " php.ini-recommended"
212395 }
213- $phpIni = Join-Path $Path " php.ini"
214- Copy-Item $phpIniProd $phpIni - Force
215- $extensionDir = Join-Path $Path " ext"
216- (Get-Content $phpIni ) -replace ' ^extension_dir = "./"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
217- (Get-Content $phpIni ) -replace ' ;\s?extension_dir = "ext"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
218- (Get-Content $phpIni ) -replace ' ;\s?date.timezone =' , " date.timezone = `" $Timezone `" " | Set-Content $phpIni
396+ $phpIni = Join-Path $installDirectory " php.ini"
397+ if (Test-Path $phpIniProd ) {
398+ Copy-Item $phpIniProd $phpIni - Force
399+
400+ $extensionDir = Join-Path $installDirectory " ext"
401+ (Get-Content $phpIni ) -replace ' ^extension_dir = "./"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
402+ (Get-Content $phpIni ) -replace ' ;\s?extension_dir = "ext"' , " extension_dir = `" $extensionDir `" " | Set-Content $phpIni
403+ (Get-Content $phpIni ) -replace ' ;\s?date.timezone =' , " date.timezone = `" $Timezone `" " | Set-Content $phpIni
404+ }
405+
406+ if (Test-Path - LiteralPath $currentLink ) {
407+ Remove-Item - LiteralPath $currentLink - Force - Recurse
408+ }
409+ New-Item - ItemType Junction - Path $currentLink - Target $installDirectory | Out-Null
410+
411+ $pathTarget = if ($EffectiveScope -eq ' AllUsers' ) { ' Machine' } else { ' User' }
412+ Set-PathEntryFirst - Target $pathTarget - Entry $currentLink
413+ Send-EnvironmentChangeBroadcast
219414
220- Write-Host " PHP $Semver downloaded to $Path "
415+ Write-Host " "
416+ Write-Host " Installed PHP ${Semver} : $installDirectory "
417+ Write-Host " It has been linked to $currentLink and added to PATH."
418+ Write-Host " Please restart any open Command Prompt/PowerShell windows or IDEs to pick up the new PATH."
419+ Write-Host " You can run 'php -v' to verify the installation in the new window."
221420} catch {
222421 Write-Error $_
223422 Exit 1
0 commit comments