[PHP-WEBMASTER] [web-php] master: Improve windows single line installer downloads script

[email protected] (Shivam Mathur)
Newsgroups php.webmaster
Message-ID <[email protected]>
Author: Shivam Mathur (shivammathur)
Date: 2026-01-05T02:15:21+05:30

Commit: https://github.com/php/web-php/commit/adeb7a1189b6a141e7def5ee0f4f169afef0f90a
Raw diff: https://github.com/php/web-php/commit/adeb7a1189b6a141e7def5ee0f4f169afef0f90a.diff

Improve windows single line installer downloads script

Improve installation path selection

Add PHP to the path after install

Changed paths:
  M  include/download-instructions/windows.ps1


Diff:

diff --git a/include/download-instructions/windows.ps1 b/include/download-instructions/windows.ps1
index d6ac1bad52..a81aa16939 100644
--- a/include/download-instructions/windows.ps1
+++ b/include/download-instructions/windows.ps1
@@ -3,19 +3,27 @@
 Downloads and sets up a specified PHP version on Windows.
 
 .PARAMETER Version
-Major.minor or full version (e.g., 7.4 or 7.4.30).
-
-.PARAMETER Path
-Destination directory (defaults to C:\php<Version>).
+Major.minor or full version (e.g., 8.4 or 8.4.15).
 
 .PARAMETER Arch
-Architecture: x64 or x86 (default: x64).
+x64 or x86 (default: x64).
 
 .PARAMETER ThreadSafe
-ThreadSafe: download Thread Safe build (default: $False).
+Download Thread Safe build (default: $False).
 
 .PARAMETER Timezone
 date.timezone string for php.ini (default: 'UTC').
+
+.PARAMETER Scope
+Auto (default), CurrentUser, AllUsers, or Custom.
+- Auto: AllUsers if elevated, otherwise CurrentUser.
+- AllUsers: Requires elevation, installs under Program Files (or Program Files (x86) for x86 arch).
+- CurrentUser: Installs under $env:LOCALAPPDATA.
+- Custom: Installs under -CustomPath (or prompts), adds to User PATH.
+
+.PARAMETER CustomPath
+Directory for Scope=Custom. Versions are installed under this directory and a "current" link is created here.
+
 #>
 
 [CmdletBinding()]
@@ -23,15 +31,23 @@ param(
     [Parameter(Mandatory = $true, Position=0)]
     [ValidatePattern('^\d+(\.\d+)?(\.\d+)?((alpha|beta|RC)\d*)?$')]
     [string]$Version,
+
     [Parameter(Mandatory = $false, Position=1)]
-    [string]$Path = "C:\php$Version",
-    [Parameter(Mandatory = $false, Position=2)]
     [ValidateSet("x64", "x86")]
     [string]$Arch = "x64",
-    [Parameter(Mandatory = $false, Position=3)]
+
+    [Parameter(Mandatory = $false, Position=2)]
     [bool]$ThreadSafe = $False,
-    [Parameter(Mandatory = $false, Position=4)]
-    [string]$Timezone = 'UTC'
+
+    [Parameter(Mandatory = $false, Position=3)]
+    [string]$Timezone = 'UTC',
+
+    [Parameter(Mandatory = $false)]
+    [ValidateSet('Auto', 'CurrentUser', 'AllUsers', 'Custom')]
+    [string]$Scope = 'Auto',
+
+    [Parameter(Mandatory = $false)]
+    [string]$CustomPath
 )
 
 Function Get-File {
@@ -52,19 +68,20 @@ Function Get-File {
     for ($i = 0; $i -lt $Retries; $i++) {
         try {
             if($OutFile -ne '') {
-                Invoke-WebRequest -Uri $Url -OutFile $OutFile -TimeoutSec $TimeoutSec
+                Invoke-WebRequest -Uri $Url -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
+                return
             } else {
-                Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSec
+                return Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
             }
-            break;
         } catch {
             if ($i -eq ($Retries - 1)) {
                 if($FallbackUrl) {
                     try {
                         if($OutFile -ne '') {
-                            Invoke-WebRequest -Uri $FallbackUrl -OutFile $OutFile -TimeoutSec $TimeoutSec
+                            Invoke-WebRequest -Uri $FallbackUrl -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
+                            return
                         } else {
-                            Invoke-WebRequest -Uri $FallbackUrl -TimeoutSec $TimeoutSec
+                            return Invoke-WebRequest -Uri $FallbackUrl -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
                         }
                     } catch {
                         throw "Failed to download the file from $Url and $FallbackUrl"
@@ -77,6 +94,87 @@ Function Get-File {
     }
 }
 
+Function Test-IsAdmin {
+    $p = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
+    return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
+}
+
+Function ConvertTo-BoolOrDefault {
+    param([string]$UserInput, [bool]$Default)
+    if ([string]::IsNullOrWhiteSpace($UserInput)) { return $Default }
+    switch -Regex ($UserInput.Trim().ToLowerInvariant()) {
+        '^(1|true|t|y|yes)$' { return $true }
+        '^(0|false|f|n|no)$' { return $false }
+        default { return $Default }
+    }
+}
+
+Function Edit-PathForCompare([string]$p) {
+    if ([string]::IsNullOrWhiteSpace($p)) { return '' }
+    return ($p.Trim().Trim('"').TrimEnd('\')).ToLowerInvariant()
+}
+
+Function Set-PathEntryFirst {
+    param(
+        [Parameter(Mandatory = $true)][ValidateSet('User','Machine')] [string]$Target,
+        [Parameter(Mandatory = $true)][string]$Entry
+    )
+
+    $entryNorm = Edit-PathForCompare $Entry
+
+    $existing = [Environment]::GetEnvironmentVariable('Path', $Target)
+    if ($null -eq $existing) { $existing = '' }
+
+    $parts = @()
+    foreach ($p in ($existing -split ';')) {
+        if (-not [string]::IsNullOrWhiteSpace($p)) {
+            if ((Edit-PathForCompare $p) -ne $entryNorm) { $parts += $p }
+        }
+    }
+    $newParts = @($Entry) + $parts
+    [Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), $Target)
+
+    $procParts = @()
+    foreach ($p in ($env:Path -split ';')) {
+        if (-not [string]::IsNullOrWhiteSpace($p)) {
+            if ((Edit-PathForCompare $p) -ne $entryNorm) { $procParts += $p }
+        }
+    }
+    $env:Path = ((@($Entry) + $procParts) -join ';')
+}
+
+function Send-EnvironmentChangeBroadcast {
+    try {
+        $sig = @'
+using System;
+using System.Runtime.InteropServices;
+public static class NativeMethods {
+  [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]
+  public static extern IntPtr SendMessageTimeout(
+    IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam,
+    uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
+}
+'@
+        Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue | Out-Null
+        $HWND_BROADCAST = [IntPtr]0xffff
+        $WM_SETTINGCHANGE = 0x001A
+        $SMTO_ABORTIFHUNG = 0x0002
+        [UIntPtr]$result = [UIntPtr]::Zero
+        [NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", $SMTO_ABORTIFHUNG, 5000, [ref]$result) | Out-Null
+    } catch { }
+}
+
+Function Test-EmptyDir([string]$Dir) {
+    if (-not (Test-Path -LiteralPath $Dir)) {
+        New-Item -ItemType Directory -Path $Dir -Force | Out-Null
+        return
+    }
+    $items = Get-ChildItem -LiteralPath $Dir -Force -ErrorAction SilentlyContinue
+    if ($items -and $items.Count -gt 0) {
+        throw "The directory '$Dir' is not empty. Please choose another location."
+    }
+}
+
 Function Get-Semver {
     [CmdletBinding()]
     param(
@@ -85,19 +183,22 @@ Function Get-Semver {
         [ValidatePattern('^\d+\.\d+$')]
         [string]$Version
     )
-    $releases = Get-File -Url "https://downloads.php.net/~windows/releases/releases.json" | ConvertFrom-Json
+
+    $jsonUrl = "https://downloads.php.net/~windows/releases/releases.json"
+    $releases = ((Get-File -Url $jsonUrl).Content | ConvertFrom-Json)
+
     $semver = $releases.$Version.version
-    if($null -eq $semver) {
-        $semver = (Get-File -Url "https://downloads.php.net/~windows/releases/archives").Links |
-                Where-Object { $_.href -match "php-($Version.[0-9]+).*" } |
-                ForEach-Object { $matches[1] } |
-                Sort-Object { [System.Version]$_ } -Descending |
-                Select-Object -First 1
-    }
-    if($null -eq $semver) {
-        throw "Unsupported PHP version: $Version"
-    }
-    return $semver
+    if ($null -ne $semver) { return [string]$semver }
+
+    $html = (Get-File -Url "https://downloads.php.net/~windows/releases/archives/").Content
+    $rx = [regex]"php-($([regex]::Escape($Version))\.[0-9]+)"
+    $found = $rx.Matches($html) | ForEach-Object { $_.Groups[1].Value } |
+        Sort-Object { [version]$_ } -Descending |
+        Select-Object -First 1
+
+    if ($null -ne $found) { return [string]$found }
+
+    throw "Unsupported PHP version series: $Version"
 }
 
 Function Get-VSVersion {
@@ -160,7 +261,7 @@ Function Get-PhpFromUrl {
     $vs = Get-VSVersion $Version
     $ts = if ($ThreadSafe) { "ts" } else { "nts" }
     $zipName = if ($ThreadSafe) { "php-$Semver-Win32-$vs-$Arch.zip" } else { "php-$Semver-$ts-Win32-$vs-$Arch.zip" }
-    $type = Get-ReleaseType $Version
+    $type = Get-ReleaseType $Semver
 
     $base = "https://downloads.php.net/~windows/$type"
     try {
@@ -175,49 +276,147 @@ Function Get-PhpFromUrl {
 }
 
 $tempFile = [IO.Path]::ChangeExtension([IO.Path]::GetTempFileName(), '.zip')
+
 try {
+    $isAdmin = Test-IsAdmin
+
+    if (-not $PSBoundParameters.ContainsKey('Arch')) {
+        Write-Host ""
+        Write-Host "What architecture would you like to install?"
+        Write-Host "Enter x64 for 64-bit"
+        Write-Host "Enter x86 for 32-bit"
+        Write-Host "Press Enter to use default ($Arch)"
+        $archSel = Read-Host "Please enter [x64/x86]"
+        if (-not [string]::IsNullOrWhiteSpace($archSel) -and @('x64','x86') -contains $archSel.Trim()) {
+            $Arch = $archSel.Trim()
+        }
+    }
+
+    if (-not $PSBoundParameters.ContainsKey('ThreadSafe')) {
+        Write-Host ""
+        Write-Host "What ThreadSafe option would you like to use?"
+        Write-Host "Enter true for ThreadSafe"
+        Write-Host "Enter false for Non-ThreadSafe"
+        Write-Host "Press Enter to use default ($ThreadSafe)"
+        $tsSel = Read-Host "Please enter [true/false]"
+        $ThreadSafe = ConvertTo-BoolOrDefault -UserInput $tsSel -Default $ThreadSafe
+    }
+
+    if (-not $PSBoundParameters.ContainsKey('Timezone')) {
+        Write-Host ""
+        Write-Host "What timezone would you like to set in php.ini?"
+        Write-Host "Press Enter to use default ($Timezone)"
+        $tzSel = Read-Host "Please enter timezone"
+        if (-not [string]::IsNullOrWhiteSpace($tzSel)) {
+            $Timezone = $tzSel.Trim()
+        }
+    }
+
+    if (-not $PSBoundParameters.ContainsKey('Scope')) {
+        Write-Host ""
+        Write-Host "Would you like to install PHP for:"
+        Write-Host "Enter 1 for Current user"
+        Write-Host "Enter 2 for All users (requires admin elevation)"
+        Write-Host "Enter 3 to install PHP at a custom path"
+        Write-Host "Press Enter to choose automatically"
+        $sel = Read-Host "Please enter [1-3]"
+        switch ($sel) {
+            '1' { $Scope = 'CurrentUser' }
+            '2' { $Scope = 'AllUsers' }
+            '3' { $Scope = 'Custom' }
+            default { $Scope = 'Auto' }
+        }
+    }
+
+    if ($Scope -eq 'Custom' -and -not $PSBoundParameters.ContainsKey('CustomPath')) {
+        $defaultCustom = if ($CustomPath) { $CustomPath } else { (Join-Path $env:LOCALAPPDATA 'Programs\PHP') }
+        Write-Host ""
+        Write-Host "Please enter the custom installation path."
+        Write-Host "Press Enter to use default ($defaultCustom)"
+        $cr = Read-Host "Please enter"
+        $CustomPath = if ([string]::IsNullOrWhiteSpace($cr)) { $defaultCustom } else { $cr.Trim() }
+    }
+
     if ($Version -match "^\d+\.\d+$") {
         $Semver = Get-Semver $Version
+        $MajorMinor = $Version
     } else {
         $Semver = $Version
-        $Semver -match '^(\d+\.\d+)' | Out-Null
-        $Version = $Matches[1]
+        if ($Semver -notmatch '^(\d+\.\d+)') { throw "Could not derive major.minor from Version '$Version'." }
+        $MajorMinor = $Matches[1]
     }
 
-    if (-not (Test-Path $Path)) {
-        try {
-            New-Item -ItemType Directory -Path $Path -ErrorAction Stop | Out-Null
-        } catch {
-            throw "Failed to create directory $Path. $_"
+    if ([version]$MajorMinor -lt [version]'5.5' -and $Arch -eq 'x64') {
+        $Arch = 'x86'
+        Write-Host "PHP series $MajorMinor does not support x64 on Windows. Using x86."
+    }
+
+    $EffectiveScope = $Scope
+    if ($Scope -eq 'Auto') {
+        $EffectiveScope = if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' }
+    }
+
+    if ($EffectiveScope -eq 'AllUsers' -and -not $isAdmin) {
+        throw "AllUsers install selected but this session is not elevated. Re-run as Administrator or choose CurrentUser/Custom."
+    }
+
+    $installRootDirectory = switch ($EffectiveScope) {
+        'CurrentUser' { Join-Path $env:LOCALAPPDATA 'Programs\PHP' }
+        'AllUsers' {
+            $pf = $env:ProgramFiles
+            if ($Arch -eq 'x86' -and ${env:ProgramFiles(x86)}) { $pf = ${env:ProgramFiles(x86)} }
+            Join-Path $pf 'PHP'
         }
-    } else {
-        $files = Get-ChildItem -Path $Path
-        if ($files.Count -gt 0) {
-            throw "The directory $Path is not empty. Please provide an empty directory."
+        'Custom' {
+            if ([string]::IsNullOrWhiteSpace($CustomPath)) { throw "Scope=Custom requires -CustomPath (or interactive input)." }
+            [Environment]::ExpandEnvironmentVariables($CustomPath)
         }
+        default { throw "Unexpected scope: $EffectiveScope" }
     }
 
-    if($Version -lt '5.5' -and $Arch -eq 'x64') {
-        $Arch = 'x86'
-        Write-Host "PHP version $Version does not support x64 architecture on Windows. Using x86 instead."
+    if (-not (Test-Path -LiteralPath $installRootDirectory)) {
+        New-Item -ItemType Directory -Path $installRootDirectory | Out-Null
     }
 
-    Write-Host "Downloading PHP $Semver to $Path"
-    Get-PhpFromUrl $Version $Semver $Arch $ThreadSafe $tempFile
-    Expand-Archive -Path $tempFile -DestinationPath $Path -Force -ErrorAction Stop
+    $tsTag = if ($ThreadSafe) { 'ts' } else { 'nts' }
+    $installDirectory = Join-Path (Join-Path (Join-Path $installRootDirectory $Semver) $tsTag) $Arch
+    $currentLink = Join-Path $installRootDirectory 'current'
+
+    Test-EmptyDir $installDirectory
+
+    Write-Host "Downloading PHP $Semver ($Arch, $tsTag) -> $installDirectory"
+    Get-PhpFromUrl $MajorMinor $Semver $Arch $ThreadSafe $tempFile
+
+    Expand-Archive -Path $tempFile -DestinationPath $installDirectory -Force -ErrorAction Stop
 
-    $phpIniProd = Join-Path $Path "php.ini-production"
+    $phpIniProd = Join-Path $installDirectory "php.ini-production"
     if(-not(Test-Path $phpIniProd)) {
-        $phpIniProd = Join-Path $Path "php.ini-recommended"
+        $phpIniProd = Join-Path $installDirectory "php.ini-recommended"
     }
-    $phpIni = Join-Path $Path "php.ini"
-    Copy-Item $phpIniProd $phpIni -Force
-    $extensionDir = Join-Path $Path "ext"
-    (Get-Content $phpIni) -replace '^extension_dir = "./"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
-    (Get-Content $phpIni) -replace ';\s?extension_dir = "ext"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
-    (Get-Content $phpIni) -replace ';\s?date.timezone =', "date.timezone = `"$Timezone`"" | Set-Content $phpIni
+    $phpIni = Join-Path $installDirectory "php.ini"
+    if (Test-Path $phpIniProd) {
+        Copy-Item $phpIniProd $phpIni -Force
+
+        $extensionDir = Join-Path $installDirectory "ext"
+        (Get-Content $phpIni) -replace '^extension_dir = "./"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
+        (Get-Content $phpIni) -replace ';\s?extension_dir = "ext"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
+        (Get-Content $phpIni) -replace ';\s?date.timezone =', "date.timezone = `"$Timezone`"" | Set-Content $phpIni
+    }
+
+    if (Test-Path -LiteralPath $currentLink) {
+        Remove-Item -LiteralPath $currentLink -Force -Recurse
+    }
+    New-Item -ItemType Junction -Path $currentLink -Target $installDirectory | Out-Null
+
+    $pathTarget = if ($EffectiveScope -eq 'AllUsers') { 'Machine' } else { 'User' }
+    Set-PathEntryFirst -Target $pathTarget -Entry $currentLink
+    Send-EnvironmentChangeBroadcast
 
-    Write-Host "PHP $Semver downloaded to $Path"
+    Write-Host ""
+    Write-Host "Installed PHP ${Semver}: $installDirectory"
+    Write-Host "It has been linked to $currentLink and added to PATH."
+    Write-Host "Please restart any open Command Prompt/PowerShell windows or IDEs to pick up the new PATH."
+    Write-Host "You can run 'php -v' to verify the installation in the new window."
 } catch {
     Write-Error $_
     Exit 1
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.